@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.js CHANGED
@@ -1866,1821 +1866,2318 @@ import { SignJWT, jwtVerify, decodeJwt } from "jose";
1866
1866
  // src/auth/sessions.ts
1867
1867
  var AUTH_SESSIONS_COLLECTION = "__auth_sessions";
1868
1868
 
1869
- // src/tasks.ts
1870
- var TASK_LOCKS_COLLECTION = "__task_locks";
1871
- var TASK_LOCKS_COLLECTION_CONFIG = {
1872
- slug: TASK_LOCKS_COLLECTION,
1873
- labels: { singular: "Task lock", plural: "Task locks" },
1874
- fields: [
1875
- { name: "name", type: "text", required: true },
1876
- // Numeric and promoted so the lock compare-and-set is a real column comparison.
1877
- { name: "lockedUntil", type: "number", required: true, promoted: true },
1878
- { name: "lockedBy", type: "text" },
1879
- { name: "lastScheduledFor", type: "date" },
1880
- { name: "lastStartedAt", type: "date" },
1881
- { name: "lastFinishedAt", type: "date" },
1882
- { name: "lastStatus", type: "text" },
1883
- { name: "lastError", type: "textarea" }
1884
- ],
1885
- access: {
1886
- read: ({ user }) => !!user?.roles?.includes("admin"),
1887
- create: () => false,
1888
- update: () => false,
1889
- delete: () => false
1890
- },
1891
- admin: { hidden: true }
1892
- };
1893
- var DEFAULT_LOCK_TIMEOUT_SECONDS = 300;
1894
- var DEFAULT_TICK_MS = 3e4;
1895
- var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
1896
- var DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
1897
- function parseCronField(raw, min2, max2, names, nameOffset, label) {
1898
- const values = /* @__PURE__ */ new Set();
1899
- const toNumber = (token) => {
1900
- const named = names?.indexOf(token.toLowerCase());
1901
- if (named !== void 0 && named >= 0) return named + nameOffset;
1902
- if (!/^\d+$/.test(token)) throw new Error(`Invalid cron ${label} "${token}".`);
1903
- return Number(token);
1904
- };
1905
- for (const part of raw.split(",")) {
1906
- const [rangePart, stepPart] = part.split("/");
1907
- const step = stepPart === void 0 ? 1 : Number(stepPart);
1908
- if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron ${label} step "${part}".`);
1909
- let start;
1910
- let end;
1911
- if (rangePart === "*") {
1912
- start = min2;
1913
- end = max2;
1914
- } else if (rangePart.includes("-")) {
1915
- const [a, b] = rangePart.split("-");
1916
- start = toNumber(a);
1917
- end = toNumber(b);
1918
- } else {
1919
- start = toNumber(rangePart);
1920
- end = stepPart === void 0 ? start : max2;
1921
- }
1922
- if (start < min2 || end > max2 || start > end) {
1923
- throw new Error(`Cron ${label} "${part}" is out of range (${min2}-${max2}).`);
1924
- }
1925
- for (let v = start; v <= end; v += step) values.add(v);
1926
- }
1927
- return values;
1869
+ // src/utils/hooks.ts
1870
+ var import_jexl3 = __toESM(require_Jexl(), 1);
1871
+
1872
+ // src/utils/declarative-hooks.ts
1873
+ var import_jexl2 = __toESM(require_Jexl(), 1);
1874
+
1875
+ // src/utils/jexl-helpers.ts
1876
+ var import_jexl = __toESM(require_Jexl(), 1);
1877
+ function slugify(val) {
1878
+ if (val === void 0 || val === null) return "";
1879
+ return String(val).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
1928
1880
  }
1929
- function parseCron(expression) {
1930
- const fields = expression.trim().split(/\s+/);
1931
- if (fields.length !== 5) {
1932
- throw new Error(`Cron expression "${expression}" must have 5 fields (minute hour day-of-month month day-of-week).`);
1933
- }
1934
- const [min2, hour, dom, mon, dow] = fields;
1935
- const daysOfWeek = parseCronField(dow, 0, 7, DAY_NAMES, 0, "day-of-week");
1936
- if (daysOfWeek.has(7)) daysOfWeek.add(0);
1937
- daysOfWeek.delete(7);
1938
- return {
1939
- minutes: parseCronField(min2, 0, 59, void 0, 0, "minute"),
1940
- hours: parseCronField(hour, 0, 23, void 0, 0, "hour"),
1941
- daysOfMonth: parseCronField(dom, 1, 31, void 0, 0, "day-of-month"),
1942
- months: parseCronField(mon, 1, 12, MONTH_NAMES, 1, "month"),
1943
- daysOfWeek,
1944
- dayFieldsBothRestricted: dom !== "*" && dow !== "*"
1945
- };
1881
+ function lower(val) {
1882
+ if (val === void 0 || val === null) return "";
1883
+ return String(val).toLowerCase();
1946
1884
  }
1947
- function dayMatches(schedule, date) {
1948
- const domOk = schedule.daysOfMonth.has(date.getUTCDate());
1949
- const dowOk = schedule.daysOfWeek.has(date.getUTCDay());
1950
- return schedule.dayFieldsBothRestricted ? domOk || dowOk : domOk && dowOk;
1885
+ function upper(val) {
1886
+ if (val === void 0 || val === null) return "";
1887
+ return String(val).toUpperCase();
1951
1888
  }
1952
- function nextCronRun(expression, after) {
1953
- const schedule = typeof expression === "string" ? parseCron(expression) : expression;
1954
- const cursor = new Date(after.getTime());
1955
- cursor.setUTCSeconds(0, 0);
1956
- cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
1957
- const limit = after.getTime() + 5 * 366 * 24 * 60 * 60 * 1e3;
1958
- while (cursor.getTime() <= limit) {
1959
- if (!schedule.months.has(cursor.getUTCMonth() + 1)) {
1960
- cursor.setUTCMonth(cursor.getUTCMonth() + 1, 1);
1961
- cursor.setUTCHours(0, 0, 0, 0);
1962
- continue;
1963
- }
1964
- if (!dayMatches(schedule, cursor)) {
1965
- cursor.setUTCDate(cursor.getUTCDate() + 1);
1966
- cursor.setUTCHours(0, 0, 0, 0);
1967
- continue;
1968
- }
1969
- if (!schedule.hours.has(cursor.getUTCHours())) {
1970
- cursor.setUTCHours(cursor.getUTCHours() + 1, 0, 0, 0);
1971
- continue;
1972
- }
1973
- if (!schedule.minutes.has(cursor.getUTCMinutes())) {
1974
- cursor.setUTCMinutes(cursor.getUTCMinutes() + 1, 0, 0);
1975
- continue;
1976
- }
1977
- return cursor;
1978
- }
1979
- throw new Error(`Cron expression "${typeof expression === "string" ? expression : "schedule"}" never matches.`);
1889
+ function trim(val) {
1890
+ if (val === void 0 || val === null) return "";
1891
+ return String(val).trim();
1980
1892
  }
1981
- function defineTask(task) {
1982
- if (!task.name || !task.name.trim()) throw new Error("A task needs a non-empty name.");
1983
- if (task.cron !== void 0) parseCron(task.cron);
1984
- if (task.lockTimeout !== void 0 && !(task.lockTimeout > 0)) {
1985
- throw new Error(`Task "${task.name}" lockTimeout must be a positive number of seconds.`);
1986
- }
1987
- return task;
1893
+ function capitalize(val) {
1894
+ if (val === void 0 || val === null) return "";
1895
+ const str = String(val).trim();
1896
+ if (!str) return "";
1897
+ return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
1988
1898
  }
1989
- function resolveLogger(config, taskName) {
1990
- const base = config.logger;
1991
- if (base && typeof base.child === "function") return base.child({ component: "tasks", task: taskName });
1992
- const prefix = `[dyrected/tasks] ${taskName}`;
1993
- return {
1994
- info: (obj, msg) => console.info(prefix, msg ?? "", obj),
1995
- warn: (obj, msg) => console.warn(prefix, msg ?? "", obj),
1996
- error: (obj, msg) => console.error(prefix, msg ?? "", obj)
1997
- };
1899
+ function truncate(val, length2, ellipsis = "...") {
1900
+ if (val === void 0 || val === null) return "";
1901
+ const str = String(val);
1902
+ const maxLen = typeof length2 === "number" && length2 > 0 ? length2 : 100;
1903
+ if (str.length <= maxLen) return str;
1904
+ return str.slice(0, maxLen).trim() + String(ellipsis ?? "...");
1998
1905
  }
1999
- function floorToMinute(date) {
2000
- const d = new Date(date.getTime());
2001
- d.setUTCSeconds(0, 0);
2002
- return d;
1906
+ function readingTime(val, wpm = 200) {
1907
+ if (val === void 0 || val === null) return 1;
1908
+ const str = String(val).trim();
1909
+ if (!str) return 1;
1910
+ const words = str.split(/\s+/).filter(Boolean).length;
1911
+ return Math.max(1, Math.ceil(words / (typeof wpm === "number" && wpm > 0 ? wpm : 200)));
2003
1912
  }
2004
- function createTaskRunner(config) {
2005
- const db = config.db;
2006
- if (!db) throw new Error("createTaskRunner requires config.db.");
2007
- const tasks = /* @__PURE__ */ new Map();
2008
- for (const task of config.tasks ?? []) {
2009
- if (tasks.has(task.name)) throw new Error(`Duplicate task name "${task.name}".`);
2010
- tasks.set(task.name, { ...task, schedule: task.cron ? parseCron(task.cron) : void 0 });
1913
+ function wordCount(val) {
1914
+ if (val === void 0 || val === null) return 0;
1915
+ const str = String(val).trim();
1916
+ if (!str) return 0;
1917
+ return str.split(/\s+/).filter(Boolean).length;
1918
+ }
1919
+ function replace(val, search, replacement) {
1920
+ if (val === void 0 || val === null) return "";
1921
+ return String(val).replace(new RegExp(String(search || ""), "g"), String(replacement ?? ""));
1922
+ }
1923
+ function startsWith(str, prefix) {
1924
+ if (str === void 0 || str === null) return false;
1925
+ return String(str).startsWith(String(prefix ?? ""));
1926
+ }
1927
+ function endsWith(str, suffix) {
1928
+ if (str === void 0 || str === null) return false;
1929
+ return String(str).endsWith(String(suffix ?? ""));
1930
+ }
1931
+ function now() {
1932
+ return (/* @__PURE__ */ new Date()).toISOString();
1933
+ }
1934
+ function today() {
1935
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1936
+ }
1937
+ function formatDate(val, style = "short") {
1938
+ if (!val) return "";
1939
+ const d = new Date(val);
1940
+ if (Number.isNaN(d.getTime())) return "";
1941
+ const styleStr = String(style).toLowerCase();
1942
+ if (styleStr === "iso" || styleStr === "date") return d.toISOString().slice(0, 10);
1943
+ if (styleStr === "datetime" || styleStr === "full") return d.toISOString();
1944
+ return d.toLocaleDateString("en-US", {
1945
+ year: "numeric",
1946
+ month: "short",
1947
+ day: "numeric"
1948
+ });
1949
+ }
1950
+ function addDays(val, days) {
1951
+ const d = val ? new Date(val) : /* @__PURE__ */ new Date();
1952
+ if (Number.isNaN(d.getTime())) return "";
1953
+ const daysNum = typeof days === "number" ? days : Number(days) || 0;
1954
+ d.setDate(d.getDate() + daysNum);
1955
+ return d.toISOString();
1956
+ }
1957
+ function diffDays(dateA, dateB) {
1958
+ const dA = dateA ? new Date(dateA) : /* @__PURE__ */ new Date();
1959
+ const dB = dateB ? new Date(dateB) : /* @__PURE__ */ new Date();
1960
+ if (Number.isNaN(dA.getTime()) || Number.isNaN(dB.getTime())) return 0;
1961
+ const diffMs = Math.abs(dA.getTime() - dB.getTime());
1962
+ return Math.floor(diffMs / (1e3 * 60 * 60 * 24));
1963
+ }
1964
+ function isPast(val) {
1965
+ if (!val) return false;
1966
+ const d = new Date(val);
1967
+ if (Number.isNaN(d.getTime())) return false;
1968
+ return d.getTime() < Date.now();
1969
+ }
1970
+ function isFuture(val) {
1971
+ if (!val) return false;
1972
+ const d = new Date(val);
1973
+ if (Number.isNaN(d.getTime())) return false;
1974
+ return d.getTime() > Date.now();
1975
+ }
1976
+ function includes(arrOrStr, item) {
1977
+ if (Array.isArray(arrOrStr)) {
1978
+ return arrOrStr.includes(item);
2011
1979
  }
2012
- const instanceId = globalThis.crypto?.randomUUID?.() ?? `inst_${Date.now()}_${Math.random().toString(36).slice(2)}`;
2013
- let timer;
2014
- let inFlight = Promise.resolve();
2015
- let ticking = false;
2016
- let ready;
2017
- function ensureReady() {
2018
- ready ??= Promise.resolve(db.sync?.([TASK_LOCKS_COLLECTION_CONFIG], [])).then(() => void 0);
2019
- ready.catch(() => {
2020
- ready = void 0;
2021
- });
2022
- return ready;
1980
+ if (typeof arrOrStr === "string") {
1981
+ return arrOrStr.includes(String(item ?? ""));
2023
1982
  }
2024
- async function ensureLockRow(name, now2) {
2025
- await ensureReady();
2026
- const existing = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
2027
- if (existing) return existing;
2028
- try {
2029
- return await db.create({
2030
- collection: TASK_LOCKS_COLLECTION,
2031
- data: {
2032
- id: name,
2033
- name,
2034
- lockedUntil: 0,
2035
- lockedBy: null,
2036
- lastScheduledFor: floorToMinute(now2).toISOString(),
2037
- lastStartedAt: null,
2038
- lastFinishedAt: null,
2039
- lastStatus: null,
2040
- lastError: null
2041
- }
2042
- });
2043
- } catch (error) {
2044
- const created = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
2045
- if (created) return created;
2046
- throw error;
2047
- }
2048
- }
2049
- async function execute(task, row, scheduledFor) {
2050
- const started = Date.now();
2051
- const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
2052
- const token = `${instanceId}:${started}`;
2053
- const where = {
2054
- id: { equals: task.name },
2055
- lockedUntil: { less_than: started }
2056
- };
2057
- if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
2058
- const claimed = await db.update({
2059
- collection: TASK_LOCKS_COLLECTION,
2060
- where,
2061
- data: {
2062
- lockedUntil: started + lockMs,
2063
- lockedBy: token,
2064
- lastStartedAt: new Date(started).toISOString(),
2065
- ...scheduledFor ? { lastScheduledFor: scheduledFor.toISOString() } : {}
2066
- }
2067
- });
2068
- if (claimed.affectedRows !== 1) {
2069
- return { name: task.name, status: "skipped", reason: "locked", durationMs: Date.now() - started };
2070
- }
2071
- const logger = resolveLogger(config, task.name);
2072
- const controller = new AbortController();
2073
- let timeout;
2074
- let status = "completed";
2075
- let errorMessage;
2076
- try {
2077
- await Promise.race([
2078
- Promise.resolve(task.run({ db, logger, signal: controller.signal, scheduledFor })),
2079
- new Promise((_, reject) => {
2080
- timeout = setTimeout(() => {
2081
- reject(new Error(`Task exceeded its lockTimeout of ${lockMs / 1e3}s.`));
2082
- controller.abort();
2083
- }, lockMs);
2084
- })
2085
- ]);
2086
- } catch (error) {
2087
- status = "failed";
2088
- errorMessage = error instanceof Error ? error.message : String(error);
2089
- logger.error({ err: error }, "Task failed");
2090
- } finally {
2091
- if (timeout) clearTimeout(timeout);
2092
- }
2093
- try {
2094
- await db.update({
2095
- collection: TASK_LOCKS_COLLECTION,
2096
- where: { id: { equals: task.name }, lockedBy: { equals: token } },
2097
- data: {
2098
- lockedUntil: 0,
2099
- lockedBy: null,
2100
- lastFinishedAt: (/* @__PURE__ */ new Date()).toISOString(),
2101
- lastStatus: status,
2102
- lastError: errorMessage ?? null
2103
- }
2104
- });
2105
- } catch (error) {
2106
- logger.warn({ err: error }, "Could not release task lock; it will expire on its own");
2107
- }
2108
- return { name: task.name, status, error: errorMessage, durationMs: Date.now() - started };
2109
- }
2110
- function track(work) {
2111
- inFlight = inFlight.then(() => work).catch(() => void 0);
2112
- return work;
2113
- }
2114
- const runner = {
2115
- async runTask(name) {
2116
- const task = tasks.get(name);
2117
- if (!task) throw new Error(`Unknown task "${name}".`);
2118
- const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
2119
- return track(execute(task, row, void 0));
2120
- },
2121
- async runDue(now2 = /* @__PURE__ */ new Date()) {
2122
- const results = [];
2123
- for (const task of tasks.values()) {
2124
- if (!task.schedule) continue;
2125
- const row = await ensureLockRow(task.name, now2);
2126
- const reference = new Date(String(row.lastScheduledFor));
2127
- const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
2128
- if (!due) {
2129
- results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
2130
- continue;
2131
- }
2132
- results.push(await track(execute(task, row, floorToMinute(now2))));
2133
- }
2134
- return results;
2135
- },
2136
- start(options) {
2137
- if (timer) return;
2138
- const intervalMs = options?.intervalMs ?? DEFAULT_TICK_MS;
2139
- const tick = () => {
2140
- if (ticking) return;
2141
- ticking = true;
2142
- runner.runDue().catch((error) => resolveLogger(config, "runner").error({ err: error }, "Task tick failed")).finally(() => {
2143
- ticking = false;
2144
- });
2145
- };
2146
- timer = setInterval(tick, intervalMs);
2147
- timer.unref?.();
2148
- setTimeout(tick, 0);
2149
- },
2150
- async stop() {
2151
- if (timer) clearInterval(timer);
2152
- timer = void 0;
2153
- await inFlight;
2154
- }
2155
- };
2156
- return runner;
1983
+ return false;
2157
1984
  }
2158
-
2159
- // src/workflows.ts
2160
- var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
2161
- var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
2162
- var EDITOR_CAPABILITIES = ["entry.edit", "entry.submit"];
2163
- var PUBLISHER_CAPABILITIES = ["entry.edit", "entry.submit", "entry.publish", "entry.unpublish"];
2164
- function definePublishingWorkflow(options = {}) {
2165
- const editors = options.editors ?? ["editor"];
2166
- const publishers = options.publishers ?? ["publisher", "admin"];
2167
- return {
2168
- initialState: "draft",
2169
- draftState: "draft",
2170
- states: [
2171
- { name: "draft", label: "Draft", color: "neutral" },
2172
- { name: "in_review", label: "In review", color: "warning" },
2173
- { name: "published", label: "Published", color: "success", published: true }
2174
- ],
2175
- transitions: [
2176
- {
2177
- name: "submit",
2178
- label: "Submit for review",
2179
- from: "draft",
2180
- to: "in_review",
2181
- requiredCapabilities: ["entry.submit"]
2182
- },
2183
- {
2184
- name: "publish",
2185
- label: "Publish",
2186
- from: "in_review",
2187
- to: "published",
2188
- requiredCapabilities: ["entry.publish"]
2189
- },
2190
- {
2191
- name: "reject",
2192
- label: "Request changes",
2193
- from: "in_review",
2194
- to: "draft",
2195
- requiredCapabilities: ["entry.publish"],
2196
- requireComment: true
2197
- },
2198
- {
2199
- name: "unpublish",
2200
- label: "Unpublish",
2201
- from: "published",
2202
- to: "draft",
2203
- requiredCapabilities: ["entry.unpublish"],
2204
- unpublish: true
2205
- }
2206
- ],
2207
- roles: [
2208
- ...editors.map((role) => ({ role, capabilities: [...EDITOR_CAPABILITIES] })),
2209
- ...publishers.map((role) => ({ role, capabilities: [...PUBLISHER_CAPABILITIES] }))
2210
- ]
2211
- };
1985
+ function join(arr, separator = ", ") {
1986
+ if (!Array.isArray(arr)) return "";
1987
+ return arr.filter((item) => item !== void 0 && item !== null).join(String(separator));
2212
1988
  }
2213
- function publishingWorkflow() {
2214
- return definePublishingWorkflow();
1989
+ function first(arr) {
1990
+ if (Array.isArray(arr)) return arr[0] ?? null;
1991
+ return null;
2215
1992
  }
2216
- function simplePublishingWorkflow() {
2217
- return {
2218
- initialState: "draft",
2219
- draftState: "draft",
2220
- states: [
2221
- { name: "draft", label: "Draft", color: "neutral" },
2222
- { name: "published", label: "Published", color: "success", published: true }
2223
- ],
2224
- transitions: [
2225
- { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
2226
- { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
2227
- ]
2228
- // Intentionally no `roles`: this lightweight workflow (synthesized from
2229
- // `drafts: true`) does no capability-based gating. Draft visibility for a
2230
- // no-roles workflow is handled in `canViewWorkflowDraft`.
2231
- };
1993
+ function last(arr) {
1994
+ if (Array.isArray(arr)) return arr[arr.length - 1] ?? null;
1995
+ return null;
2232
1996
  }
2233
- function publicMetadata(meta) {
2234
- const { availableTransitions: _availableTransitions, ...safe } = meta;
2235
- return safe;
1997
+ function compact(arr) {
1998
+ if (!Array.isArray(arr)) return [];
1999
+ return arr.filter((item) => item !== void 0 && item !== null && item !== "");
2236
2000
  }
2237
- function workflowCapabilities(workflow, user) {
2238
- const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
2239
- const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
2240
- for (const mapping of workflow.roles ?? []) {
2241
- if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
2242
- }
2243
- return capabilities;
2001
+ function unique(arr) {
2002
+ if (!Array.isArray(arr)) return [];
2003
+ return Array.from(new Set(arr));
2244
2004
  }
2245
- function canViewWorkflowDraft(workflow, user) {
2246
- if (!user) return false;
2247
- const capabilities = workflowCapabilities(workflow, user);
2248
- if (capabilities.has("entry.edit")) return true;
2249
- if (workflow.transitions.some(
2250
- (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
2251
- )) {
2252
- return true;
2253
- }
2254
- if (!workflow.roles || workflow.roles.length === 0) return true;
2255
- return false;
2005
+ function length(val) {
2006
+ if (Array.isArray(val) || typeof val === "string") return val.length;
2007
+ if (val && typeof val === "object") return Object.keys(val).length;
2008
+ return 0;
2256
2009
  }
2257
- function availableWorkflowTransitions(workflow, state, user) {
2258
- const capabilities = workflowCapabilities(workflow, user);
2259
- return workflow.transitions.filter((transition) => {
2260
- const from = Array.isArray(transition.from) ? transition.from : [transition.from];
2261
- return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
2262
- });
2010
+ function round(val, decimals = 0) {
2011
+ const num = Number(val);
2012
+ if (Number.isNaN(num)) return 0;
2013
+ const dec = typeof decimals === "number" ? decimals : Number(decimals) || 0;
2014
+ const factor = Math.pow(10, dec);
2015
+ return Math.round(num * factor) / factor;
2263
2016
  }
2264
- function initializeWorkflowDocument(data, workflow) {
2265
- return {
2266
- ...data,
2267
- __workflow: { state: workflow.initialState, revision: 1 }
2268
- };
2017
+ function clamp(val, minVal, maxVal) {
2018
+ const num = Number(val) || 0;
2019
+ const min2 = Number(minVal) || 0;
2020
+ const max2 = Number(maxVal) || 0;
2021
+ return Math.min(Math.max(num, min2), max2);
2269
2022
  }
2270
- function publishedStateName(workflow) {
2271
- return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
2023
+ function ceil(val) {
2024
+ return Math.ceil(Number(val) || 0);
2272
2025
  }
2273
- function materializeWorkflowDocument(doc, workflow, user) {
2274
- const meta = doc.__workflow;
2275
- const publishedState = publishedStateName(workflow);
2276
- if (!meta) {
2277
- const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
2278
- const state = publishedStateName(workflow);
2279
- return {
2280
- ...working2,
2281
- _workflow: {
2282
- state,
2283
- revision: 1,
2284
- availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
2285
- }
2286
- };
2287
- }
2288
- const { __published, __workflow, ...working } = doc;
2289
- if (!canViewWorkflowDraft(workflow, user)) {
2290
- if (!__published || typeof __published !== "object") {
2291
- if (meta.state === publishedState) {
2292
- return { ...working, _workflow: publicMetadata(meta) };
2293
- }
2294
- return null;
2295
- }
2296
- return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
2297
- }
2298
- return {
2299
- ...working,
2300
- _workflow: {
2301
- ...meta,
2302
- availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
2303
- }
2304
- };
2026
+ function floor(val) {
2027
+ return Math.floor(Number(val) || 0);
2305
2028
  }
2306
- function eventId() {
2307
- return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
2029
+ function abs(val) {
2030
+ return Math.abs(Number(val) || 0);
2308
2031
  }
2309
- function createLifecycleEvent(args) {
2310
- return {
2311
- id: eventId(),
2312
- name: args.name,
2313
- collection: args.collection,
2314
- documentId: args.documentId,
2315
- occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
2316
- actorId: args.actorId,
2317
- payload: args.payload,
2318
- attempts: 0,
2319
- status: "pending"
2320
- };
2032
+ function min(...args) {
2033
+ const nums = args.flat().map((n) => Number(n) || 0);
2034
+ return nums.length ? Math.min(...nums) : 0;
2321
2035
  }
2322
- async function persistEvent(db, event) {
2323
- await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
2036
+ function max(...args) {
2037
+ const nums = args.flat().map((n) => Number(n) || 0);
2038
+ return nums.length ? Math.max(...nums) : 0;
2324
2039
  }
2325
- async function dispatchLifecycleEvent(config, event) {
2326
- const db = config.db;
2327
- if (!db || !config.events?.handlers.length) return;
2328
- const maxAttempts = config.events.maxAttempts ?? 8;
2329
- const retryDelayMs = config.events.retryDelayMs ?? 1e3;
2330
- const attempts = event.attempts + 1;
2331
- try {
2332
- await db.update({
2333
- collection: LIFECYCLE_EVENTS_COLLECTION,
2334
- id: event.id,
2335
- data: { status: "processing", attempts }
2336
- });
2337
- for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
2338
- await db.update({
2339
- collection: LIFECYCLE_EVENTS_COLLECTION,
2340
- id: event.id,
2341
- data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
2342
- });
2343
- } catch (error) {
2344
- const exhausted = attempts >= maxAttempts;
2345
- await db.update({
2346
- collection: LIFECYCLE_EVENTS_COLLECTION,
2347
- id: event.id,
2348
- data: {
2349
- status: "failed",
2350
- attempts,
2351
- lastError: error instanceof Error ? error.message : String(error),
2352
- nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
2353
- }
2354
- });
2355
- }
2040
+ function pow(base, exp) {
2041
+ return Math.pow(Number(base) || 0, Number(exp) || 0);
2356
2042
  }
2357
- async function dispatchPendingLifecycleEvents(config, limit = 50) {
2358
- if (!config.db || !config.events?.handlers.length) return 0;
2359
- const maxAttempts = config.events.maxAttempts ?? 8;
2360
- const result = await config.db.find({
2361
- collection: LIFECYCLE_EVENTS_COLLECTION,
2362
- where: { status: { in: ["pending", "failed"] } },
2363
- sort: "occurredAt",
2364
- limit
2365
- });
2366
- const now2 = Date.now();
2367
- const due = result.docs.filter(
2368
- (doc) => Number(doc.attempts ?? 0) < maxAttempts && (!doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now2)
2369
- );
2370
- for (const event of due) await dispatchLifecycleEvent(config, event);
2371
- return due.length;
2043
+ function sqrt(val) {
2044
+ return Math.sqrt(Number(val) || 0);
2372
2045
  }
2373
- async function saveWorkflowDraft(args) {
2374
- const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
2375
- const db = config.db;
2376
- const workflow = collection.workflow;
2377
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2378
- const previous = originalDoc.__workflow;
2379
- const isLegacyPublishedDoc = !previous;
2380
- const baselineRevision = previous?.revision ?? 1;
2381
- const event = createLifecycleEvent({
2382
- name: "revision.created",
2383
- collection: collection.slug,
2384
- documentId: id,
2385
- actorId: user?.sub,
2386
- payload: {
2387
- revision: baselineRevision + 1,
2388
- previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
2389
- }
2390
- });
2391
- const doc = await db.transaction(async (tx) => {
2392
- const nextMeta = {
2393
- ...previous ?? {},
2394
- state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
2395
- revision: baselineRevision + 1,
2396
- ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
2397
- };
2398
- const updateData = {
2399
- ...data,
2400
- __workflow: nextMeta
2401
- };
2402
- if (isLegacyPublishedDoc) {
2403
- const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
2404
- updateData.__published = publishedSnapshot;
2405
- }
2406
- const updated = await tx.update({ collection: collection.slug, id, data: updateData });
2407
- await persistEvent(tx, event);
2408
- await beforeCommit?.(tx, updated);
2409
- return updated;
2410
- });
2411
- void dispatchLifecycleEvent(config, event);
2412
- return { doc, event };
2046
+ function sum(arr) {
2047
+ if (!Array.isArray(arr)) return Number(arr) || 0;
2048
+ return arr.reduce((acc, val) => acc + (Number(val) || 0), 0);
2413
2049
  }
2414
- async function createWorkflowDocument(args) {
2415
- const { config, collection, data, user, beforeCommit } = args;
2416
- const db = config.db;
2417
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2418
- let event;
2419
- const doc = await db.transaction(async (tx) => {
2420
- const created = await tx.create({ collection: collection.slug, data });
2421
- event = createLifecycleEvent({
2422
- name: "revision.created",
2423
- collection: collection.slug,
2424
- documentId: created.id,
2425
- actorId: user?.sub,
2426
- payload: { revision: 1, previousRevision: null }
2427
- });
2428
- await persistEvent(tx, event);
2429
- await beforeCommit?.(tx, created);
2430
- return created;
2431
- });
2432
- void dispatchLifecycleEvent(config, event);
2433
- return { doc, event };
2050
+ function count(val) {
2051
+ if (Array.isArray(val)) return val.length;
2052
+ if (typeof val === "string") return val.length;
2053
+ if (val !== void 0 && val !== null && val !== false) return 1;
2054
+ return 0;
2434
2055
  }
2435
- async function transitionWorkflow(args) {
2436
- const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
2437
- const db = config.db;
2438
- const workflow = collection.workflow;
2439
- if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
2440
- const original = await db.findOne({ collection: collection.slug, id });
2441
- if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
2442
- const meta = original.__workflow;
2443
- if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
2444
- if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
2445
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
2446
- statusCode: 409
2447
- });
2448
- }
2449
- const transition = workflow.transitions.find((item) => item.name === transitionName);
2450
- const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
2451
- if (!transition || !fromStates.includes(meta.state)) {
2452
- throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
2453
- statusCode: 409
2454
- });
2455
- }
2456
- if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
2457
- throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
2056
+ function defaultValue(val, fallback) {
2057
+ if (val === void 0 || val === null || val === "") return fallback;
2058
+ return val;
2059
+ }
2060
+ function coalesce(...args) {
2061
+ for (const arg of args) {
2062
+ if (arg !== void 0 && arg !== null && arg !== "") return arg;
2458
2063
  }
2459
- if (transition.requireComment && !comment?.trim()) {
2460
- throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
2064
+ return null;
2065
+ }
2066
+ function isEmpty(val) {
2067
+ if (val === void 0 || val === null || val === "") return true;
2068
+ if (Array.isArray(val)) return val.length === 0;
2069
+ if (typeof val === "object") return Object.keys(val).length === 0;
2070
+ return false;
2071
+ }
2072
+ function get(obj, path, fallback = null) {
2073
+ if (obj === void 0 || obj === null || typeof obj !== "object") return fallback;
2074
+ if (typeof path !== "string") return fallback;
2075
+ const parts = path.split(".").filter(Boolean);
2076
+ let curr = obj;
2077
+ for (const part of parts) {
2078
+ if (curr === void 0 || curr === null || typeof curr !== "object") return fallback;
2079
+ curr = curr[part];
2461
2080
  }
2462
- const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
2463
- for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
2464
- const events = [
2465
- createLifecycleEvent({
2466
- name: "workflow.transitioned",
2467
- collection: collection.slug,
2468
- documentId: id,
2469
- actorId: user?.sub,
2470
- payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
2471
- })
2472
- ];
2473
- const targetState = workflow.states.find((state) => state.name === transition.to);
2474
- if (targetState.published) {
2475
- events.push(
2476
- createLifecycleEvent({
2477
- name: "entry.published",
2478
- collection: collection.slug,
2479
- documentId: id,
2480
- actorId: user?.sub,
2481
- payload: { revision: meta.revision }
2482
- })
2483
- );
2484
- } else if (transition.unpublish) {
2485
- events.push(
2486
- createLifecycleEvent({
2487
- name: "entry.unpublished",
2488
- collection: collection.slug,
2489
- documentId: id,
2490
- actorId: user?.sub,
2491
- payload: { revision: meta.publishedRevision }
2492
- })
2493
- );
2494
- }
2495
- const updated = await db.transaction(async (tx) => {
2496
- const locked = await tx.findOne({ collection: collection.slug, id });
2497
- if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
2498
- const lockedMeta = locked.__workflow;
2499
- if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
2500
- throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
2501
- statusCode: 409
2502
- });
2503
- }
2504
- const now2 = (/* @__PURE__ */ new Date()).toISOString();
2505
- const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
2506
- const nextMeta = {
2507
- ...lockedMeta,
2508
- state: transition.to,
2509
- ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
2510
- ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
2511
- };
2512
- const data = { __workflow: nextMeta };
2513
- if (targetState.published) data.__published = working;
2514
- if (transition.unpublish) data.__published = null;
2515
- const next = await tx.update({ collection: collection.slug, id, data });
2516
- if (transition.onTransition) {
2517
- await transition.onTransition({
2518
- transition,
2519
- from: lockedMeta.state,
2520
- to: transition.to,
2521
- doc: locked,
2522
- input,
2523
- comment,
2524
- user,
2525
- req,
2526
- tx
2527
- });
2528
- }
2529
- await tx.create({
2530
- collection: WORKFLOW_HISTORY_COLLECTION,
2531
- data: {
2532
- collection: collection.slug,
2533
- documentId: id,
2534
- transition: transition.name,
2535
- from: lockedMeta.state,
2536
- to: transition.to,
2537
- revision: lockedMeta.revision,
2538
- comment: comment ?? null,
2539
- actorId: user?.sub ?? null,
2540
- createdAt: now2
2541
- }
2542
- });
2543
- for (const event of events) await persistEvent(tx, event);
2544
- if (collection.audit) {
2545
- await tx.create({
2546
- collection: "__audit",
2547
- data: {
2548
- collection: collection.slug,
2549
- documentId: id,
2550
- operation: "workflow.transition",
2551
- user: user?.sub ?? null,
2552
- timestamp: now2,
2553
- changes: JSON.stringify({
2554
- transition: transition.name,
2555
- from: lockedMeta.state,
2556
- to: transition.to,
2557
- revision: lockedMeta.revision
2558
- })
2559
- }
2560
- });
2081
+ return curr === void 0 || curr === null ? fallback : curr;
2082
+ }
2083
+ function registerJexlHelpers(jexlInstance = import_jexl.default) {
2084
+ const helpers = {
2085
+ // Strings
2086
+ slugify,
2087
+ lower,
2088
+ upper,
2089
+ trim,
2090
+ capitalize,
2091
+ truncate,
2092
+ readingTime,
2093
+ wordCount,
2094
+ replace,
2095
+ startsWith,
2096
+ endsWith,
2097
+ // Dates
2098
+ now,
2099
+ today,
2100
+ formatDate,
2101
+ addDays,
2102
+ diffDays,
2103
+ isPast,
2104
+ isFuture,
2105
+ // Arrays
2106
+ includes,
2107
+ join,
2108
+ first,
2109
+ last,
2110
+ compact,
2111
+ unique,
2112
+ length,
2113
+ // Math
2114
+ round,
2115
+ clamp,
2116
+ ceil,
2117
+ floor,
2118
+ abs,
2119
+ min,
2120
+ max,
2121
+ pow,
2122
+ sqrt,
2123
+ sum,
2124
+ count,
2125
+ // Objects & Logical
2126
+ default: defaultValue,
2127
+ coalesce,
2128
+ isEmpty,
2129
+ get
2130
+ };
2131
+ for (const [name, fn] of Object.entries(helpers)) {
2132
+ try {
2133
+ jexlInstance.addFunction(name, fn);
2134
+ } catch {
2561
2135
  }
2562
- return next;
2563
- });
2564
- for (const event of events) void dispatchLifecycleEvent(config, event);
2565
- for (const hook of workflow.hooks?.afterTransition ?? []) {
2566
2136
  try {
2567
- await hook({ ...hookContext, doc: updated, event: events[0] });
2568
- } catch (error) {
2569
- try {
2570
- const obs = config?.__observability;
2571
- obs?.recordWorkflowHookFailure?.({
2572
- collection: collection.slug,
2573
- transition: transition.name
2574
- });
2575
- const logger = config?.logger;
2576
- if (logger && typeof logger.child === "function") {
2577
- logger.child({ component: "workflow" }).error({
2578
- err: error,
2579
- msg: "afterTransition hook failed",
2580
- collection: collection.slug,
2581
- transition: transition.name,
2582
- documentId: id
2583
- });
2584
- } else if (logger && typeof logger.error === "function") {
2585
- logger.error({
2586
- err: error,
2587
- msg: "afterTransition hook failed",
2588
- collection: collection.slug,
2589
- transition: transition.name,
2590
- documentId: id
2591
- });
2592
- } else {
2593
- console.error("afterTransition hook failed:", error);
2594
- }
2595
- } catch {
2596
- console.error("afterTransition hook failed:", error);
2597
- }
2137
+ jexlInstance.addTransform(name, fn);
2138
+ } catch {
2598
2139
  }
2599
2140
  }
2600
- return updated;
2601
- }
2602
-
2603
- // src/utils/admin-auth.ts
2604
- function getAdminAuthCollection(config) {
2605
- const adminCollection = resolveAdminAuthCollection(config.collections, config.adminAuth?.collectionSlug);
2606
- return adminCollection ?? null;
2607
- }
2608
- function resolveAdminAuthCollection(collections, collectionSlug) {
2609
- const adminsCollection = collections.find((collection) => collection.slug === "__admins");
2610
- if (adminsCollection) return adminsCollection;
2611
- if (collectionSlug) {
2612
- const requestedCollection = collections.find((collection) => collection.slug === collectionSlug);
2613
- if (requestedCollection) return requestedCollection;
2141
+ try {
2142
+ jexlInstance.addBinaryOp("and", 20, (left, right) => Boolean(left && right));
2143
+ jexlInstance.addBinaryOp("or", 10, (left, right) => Boolean(left || right));
2144
+ jexlInstance.addBinaryOp("in", 30, (left, right) => {
2145
+ if (Array.isArray(right)) return right.includes(left);
2146
+ if (typeof right === "string") return right.includes(String(left ?? ""));
2147
+ return false;
2148
+ });
2149
+ } catch {
2614
2150
  }
2615
- return collections.find((collection) => collection.auth);
2616
- }
2617
- function getPublicAdminAuthConfig(adminAuth, collections) {
2618
- const providers = (adminAuth?.providers ?? []).map((provider) => ({
2619
- id: provider.id,
2620
- type: provider.type,
2621
- displayName: provider.displayName || humanizeProviderName(provider.id, provider.type),
2622
- autoRedirect: provider.autoRedirect
2623
- }));
2624
- const resolvedCollectionSlug = collections ? resolveAdminAuthCollection(collections, adminAuth?.collectionSlug)?.slug : adminAuth?.collectionSlug;
2625
- return {
2626
- mode: adminAuth?.mode ?? "local",
2627
- collectionSlug: resolvedCollectionSlug,
2628
- provisioningMode: adminAuth?.provisioningMode,
2629
- providers
2630
- };
2631
2151
  }
2632
- function humanizeProviderName(id, type) {
2633
- const cleaned = id.replace(/[-_]+/g, " ").trim();
2634
- if (!cleaned) return type.toUpperCase();
2635
- return cleaned.replace(/\b\w/g, (char) => char.toUpperCase());
2636
- }
2637
- function getAdminRoleForCollection(collection) {
2638
- if (!collection?.auth) return "admin";
2639
- if (typeof collection.auth === "object" && collection.auth.adminRole) {
2640
- return collection.auth.adminRole;
2152
+ registerJexlHelpers(import_jexl.default);
2153
+ async function evaluateJexl(expression, context = {}) {
2154
+ if (!expression || typeof expression !== "string") return void 0;
2155
+ try {
2156
+ const evalContext = {
2157
+ null: null,
2158
+ undefined: void 0,
2159
+ ...context,
2160
+ math: Math,
2161
+ Math
2162
+ };
2163
+ return await import_jexl.default.eval(expression, evalContext);
2164
+ } catch (_err) {
2165
+ return void 0;
2641
2166
  }
2642
- return "admin";
2643
2167
  }
2644
- function isUserAdmin(user, collection) {
2645
- if (!user) return false;
2646
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
2647
- if (roles.length === 0) return false;
2648
- const configuredRole = getAdminRoleForCollection(collection);
2649
- if (roles.includes(configuredRole)) return true;
2650
- return roles.includes("admin") || roles.includes("super_admin");
2651
- }
2652
-
2653
- // src/utils/block-references.ts
2654
- function mergeUniqueBlocks(blocks) {
2655
- const seen = /* @__PURE__ */ new Map();
2656
- for (const block of blocks) {
2657
- const existing = seen.get(block.slug);
2658
- if (existing && existing !== block) {
2659
- throw new Error(
2660
- `Duplicate block slug "${block.slug}" found in the reusable block registry. Block slugs must be unique.`
2661
- );
2662
- }
2663
- if (!existing) seen.set(block.slug, block);
2664
- }
2665
- return Array.from(seen.values());
2666
- }
2667
- function buildRegistry(blocks) {
2668
- return new Map(blocks.map((block) => [block.slug, block]));
2669
- }
2670
- function resolveField(field, registry, blockCache) {
2671
- const next = { ...field };
2672
- if (next.fields) {
2673
- next.fields = next.fields.map(
2674
- (child) => resolveField(child, registry, blockCache)
2675
- );
2676
- }
2677
- if (next.type !== "blocks") return next;
2678
- const hasInlineBlocks = Array.isArray(next.blocks) && next.blocks.length > 0;
2679
- const hasReferences = Array.isArray(next.blockReferences) && next.blockReferences.length > 0;
2680
- if (hasInlineBlocks && hasReferences) {
2681
- throw new Error(
2682
- `Blocks field "${next.name ?? "(unnamed)"}" cannot define both "blocks" and "blockReferences". Use one or the other.`
2683
- );
2684
- }
2685
- if (hasReferences) {
2686
- next.blocks = next.blockReferences.map((slug) => {
2687
- const block = registry.get(slug);
2688
- if (!block) {
2689
- throw new Error(
2690
- `Unknown block reference "${slug}" on blocks field "${next.name ?? "(unnamed)"}". Add it to defineConfig({ blocks: [...] }).`
2691
- );
2692
- }
2693
- return resolveBlock(block, registry, blockCache);
2694
- });
2695
- return next;
2696
- }
2697
- if (hasInlineBlocks) {
2698
- next.blocks = next.blocks.map(
2699
- (block) => resolveBlock(block, registry, blockCache)
2700
- );
2701
- }
2702
- return next;
2703
- }
2704
- function resolveBlock(block, registry, blockCache) {
2705
- const cached = blockCache.get(block);
2706
- if (cached) return cached;
2707
- const resolved = {
2708
- ...block,
2709
- fields: []
2710
- };
2711
- blockCache.set(block, resolved);
2712
- resolved.fields = block.fields.map(
2713
- (field) => resolveField(field, registry, blockCache)
2714
- );
2715
- return resolved;
2716
- }
2717
- function resolveFields(fields, registry, blockCache) {
2718
- return fields.map((field) => resolveField(field, registry, blockCache));
2719
- }
2720
- function normalizeSchemaFragment(fragment) {
2721
- const reusableBlocks = mergeUniqueBlocks(fragment.blocks ?? []);
2722
- const registry = buildRegistry(reusableBlocks);
2723
- const blockCache = /* @__PURE__ */ new WeakMap();
2724
- const resolvedBlocks = reusableBlocks.map(
2725
- (block) => resolveBlock(block, registry, blockCache)
2726
- );
2727
- const resolvedCollections = (fragment.collections ?? []).map(
2728
- (collection) => ({
2729
- ...collection,
2730
- fields: resolveFields(collection.fields ?? [], registry, blockCache)
2731
- })
2732
- );
2733
- const resolvedGlobals = (fragment.globals ?? []).map((global) => ({
2734
- ...global,
2735
- fields: resolveFields(global.fields ?? [], registry, blockCache)
2736
- }));
2737
- return {
2738
- ...fragment,
2739
- ...fragment.blocks ? { blocks: resolvedBlocks } : {},
2740
- ...fragment.collections ? { collections: resolvedCollections } : {},
2741
- ...fragment.globals ? { globals: resolvedGlobals } : {}
2742
- };
2168
+ function evaluateJexlSync(expression, context = {}) {
2169
+ if (!expression || typeof expression !== "string") return void 0;
2170
+ try {
2171
+ const evalContext = {
2172
+ null: null,
2173
+ undefined: void 0,
2174
+ ...context,
2175
+ math: Math,
2176
+ Math
2177
+ };
2178
+ return import_jexl.default.evalSync(expression, evalContext);
2179
+ } catch (_err) {
2180
+ return void 0;
2181
+ }
2743
2182
  }
2183
+ var BUILTIN_JEXL_HELPERS = [
2184
+ // Strings
2185
+ "slugify",
2186
+ "lower",
2187
+ "upper",
2188
+ "trim",
2189
+ "capitalize",
2190
+ "truncate",
2191
+ "readingTime",
2192
+ "wordCount",
2193
+ "replace",
2194
+ "startsWith",
2195
+ "endsWith",
2196
+ // Dates
2197
+ "now",
2198
+ "today",
2199
+ "formatDate",
2200
+ "addDays",
2201
+ "diffDays",
2202
+ "isPast",
2203
+ "isFuture",
2204
+ // Arrays
2205
+ "includes",
2206
+ "join",
2207
+ "first",
2208
+ "last",
2209
+ "compact",
2210
+ "unique",
2211
+ "length",
2212
+ // Math
2213
+ "round",
2214
+ "clamp",
2215
+ // Objects & Logical
2216
+ "default",
2217
+ "coalesce",
2218
+ "isEmpty",
2219
+ "get"
2220
+ ];
2744
2221
 
2745
2222
  // src/utils/declarative-hooks.ts
2746
- var import_jexl2 = __toESM(require_Jexl(), 1);
2747
-
2748
- // src/utils/jexl-helpers.ts
2749
- var import_jexl = __toESM(require_Jexl(), 1);
2750
- function slugify(val) {
2751
- if (val === void 0 || val === null) return "";
2752
- return String(val).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
2753
- }
2754
- function lower(val) {
2755
- if (val === void 0 || val === null) return "";
2756
- return String(val).toLowerCase();
2757
- }
2758
- function upper(val) {
2759
- if (val === void 0 || val === null) return "";
2760
- return String(val).toUpperCase();
2761
- }
2762
- function trim(val) {
2763
- if (val === void 0 || val === null) return "";
2764
- return String(val).trim();
2223
+ registerJexlHelpers(import_jexl2.default);
2224
+ var SURFACE_ROOTS = {
2225
+ "collection.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
2226
+ "collection.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
2227
+ "collection.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
2228
+ "global.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
2229
+ "global.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
2230
+ "global.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
2231
+ "field.beforeChange": ["value", "data", "originalDoc", "user", ...BUILTIN_JEXL_HELPERS],
2232
+ "field.admin.onChange": ["value", "siblingData", "data", ...BUILTIN_JEXL_HELPERS]
2233
+ };
2234
+ var ACCESS_ROOTS = ["user", "req", "doc", "data", "id", "config", ...BUILTIN_JEXL_HELPERS];
2235
+ var ADMIN_CONDITION_RESERVED_ROOTS = ["data", "siblingData", "user", "id", ...BUILTIN_JEXL_HELPERS];
2236
+ var PREVIEW_URL_RESERVED_ROOTS = ["siteUrl", "id", ...BUILTIN_JEXL_HELPERS];
2237
+ var COMPILE_CACHE = /* @__PURE__ */ new Map();
2238
+ function compileExpression(expression) {
2239
+ const cached = COMPILE_CACHE.get(expression);
2240
+ if (cached) return cached;
2241
+ const compiled = import_jexl2.default.compile(expression);
2242
+ const next = { ast: compiled._ast };
2243
+ COMPILE_CACHE.set(expression, next);
2244
+ return next;
2765
2245
  }
2766
- function capitalize(val) {
2767
- if (val === void 0 || val === null) return "";
2768
- const str = String(val).trim();
2769
- if (!str) return "";
2770
- return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
2246
+ function isPlainObject(value) {
2247
+ return !!value && typeof value === "object" && !Array.isArray(value);
2771
2248
  }
2772
- function truncate(val, length2, ellipsis = "...") {
2773
- if (val === void 0 || val === null) return "";
2774
- const str = String(val);
2775
- const maxLen = typeof length2 === "number" && length2 > 0 ? length2 : 100;
2776
- if (str.length <= maxLen) return str;
2777
- return str.slice(0, maxLen).trim() + String(ellipsis ?? "...");
2249
+ function quoteSegment(value) {
2250
+ return `["${value.replace(/"/g, '\\"')}"]`;
2778
2251
  }
2779
- function readingTime(val, wpm = 200) {
2780
- if (val === void 0 || val === null) return 1;
2781
- const str = String(val).trim();
2782
- if (!str) return 1;
2783
- const words = str.split(/\s+/).filter(Boolean).length;
2784
- return Math.max(1, Math.ceil(words / (typeof wpm === "number" && wpm > 0 ? wpm : 200)));
2252
+ function fieldSegment(field, index) {
2253
+ return typeof field.name === "string" && field.name.length > 0 ? `fields${quoteSegment(field.name)}` : `fields[${index}]`;
2785
2254
  }
2786
- function wordCount(val) {
2787
- if (val === void 0 || val === null) return 0;
2788
- const str = String(val).trim();
2789
- if (!str) return 0;
2790
- return str.split(/\s+/).filter(Boolean).length;
2255
+ function blockSegment(block, index) {
2256
+ return typeof block.slug === "string" && block.slug.length > 0 ? `blocks${quoteSegment(block.slug)}` : `blocks[${index}]`;
2791
2257
  }
2792
- function replace(val, search, replacement) {
2793
- if (val === void 0 || val === null) return "";
2794
- return String(val).replace(new RegExp(String(search || ""), "g"), String(replacement ?? ""));
2258
+ function collectionSegment(collection, index) {
2259
+ return typeof collection.slug === "string" && collection.slug.length > 0 ? `collections${quoteSegment(collection.slug)}` : `collections[${index}]`;
2795
2260
  }
2796
- function startsWith(str, prefix) {
2797
- if (str === void 0 || str === null) return false;
2798
- return String(str).startsWith(String(prefix ?? ""));
2261
+ function globalSegment(global, index) {
2262
+ return typeof global.slug === "string" && global.slug.length > 0 ? `globals${quoteSegment(global.slug)}` : `globals[${index}]`;
2799
2263
  }
2800
- function endsWith(str, suffix) {
2801
- if (str === void 0 || str === null) return false;
2802
- return String(str).endsWith(String(suffix ?? ""));
2264
+ function colorize(value, code, enabled) {
2265
+ return enabled ? `\x1B[${code}m${value}\x1B[0m` : value;
2803
2266
  }
2804
- function now() {
2805
- return (/* @__PURE__ */ new Date()).toISOString();
2267
+ function collectRootIdentifiers(node, roots, seen) {
2268
+ if (!node || typeof node !== "object" || seen.has(node)) return;
2269
+ seen.add(node);
2270
+ const candidate = node;
2271
+ if (candidate.type === "Identifier") {
2272
+ if (candidate.from) {
2273
+ collectRootIdentifiers(candidate.from, roots, seen);
2274
+ return;
2275
+ }
2276
+ if (typeof candidate.value === "string" && candidate.value !== "null") {
2277
+ roots.add(candidate.value);
2278
+ }
2279
+ return;
2280
+ }
2281
+ if (Array.isArray(node)) {
2282
+ for (const entry of node) collectRootIdentifiers(entry, roots, seen);
2283
+ return;
2284
+ }
2285
+ for (const value of Object.values(node)) {
2286
+ if (Array.isArray(value)) {
2287
+ for (const entry of value) collectRootIdentifiers(entry, roots, seen);
2288
+ continue;
2289
+ }
2290
+ if (value && typeof value === "object") {
2291
+ collectRootIdentifiers(value, roots, seen);
2292
+ }
2293
+ }
2806
2294
  }
2807
- function today() {
2808
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2295
+ function getInvalidRoots(expression, allowedRoots) {
2296
+ const { ast } = compileExpression(expression);
2297
+ const roots = /* @__PURE__ */ new Set();
2298
+ collectRootIdentifiers(ast, roots, /* @__PURE__ */ new Set());
2299
+ const allowed = new Set(allowedRoots);
2300
+ return [...roots].filter((root) => !allowed.has(root));
2809
2301
  }
2810
- function formatDate(val, style = "short") {
2811
- if (!val) return "";
2812
- const d = new Date(val);
2813
- if (Number.isNaN(d.getTime())) return "";
2814
- const styleStr = String(style).toLowerCase();
2815
- if (styleStr === "iso" || styleStr === "date") return d.toISOString().slice(0, 10);
2816
- if (styleStr === "datetime" || styleStr === "full") return d.toISOString();
2817
- return d.toLocaleDateString("en-US", {
2818
- year: "numeric",
2819
- month: "short",
2820
- day: "numeric"
2821
- });
2822
- }
2823
- function addDays(val, days) {
2824
- const d = val ? new Date(val) : /* @__PURE__ */ new Date();
2825
- if (Number.isNaN(d.getTime())) return "";
2826
- const daysNum = typeof days === "number" ? days : Number(days) || 0;
2827
- d.setDate(d.getDate() + daysNum);
2828
- return d.toISOString();
2829
- }
2830
- function diffDays(dateA, dateB) {
2831
- const dA = dateA ? new Date(dateA) : /* @__PURE__ */ new Date();
2832
- const dB = dateB ? new Date(dateB) : /* @__PURE__ */ new Date();
2833
- if (Number.isNaN(dA.getTime()) || Number.isNaN(dB.getTime())) return 0;
2834
- const diffMs = Math.abs(dA.getTime() - dB.getTime());
2835
- return Math.floor(diffMs / (1e3 * 60 * 60 * 24));
2836
- }
2837
- function isPast(val) {
2838
- if (!val) return false;
2839
- const d = new Date(val);
2840
- if (Number.isNaN(d.getTime())) return false;
2841
- return d.getTime() < Date.now();
2842
- }
2843
- function isFuture(val) {
2844
- if (!val) return false;
2845
- const d = new Date(val);
2846
- if (Number.isNaN(d.getTime())) return false;
2847
- return d.getTime() > Date.now();
2848
- }
2849
- function includes(arrOrStr, item) {
2850
- if (Array.isArray(arrOrStr)) {
2851
- return arrOrStr.includes(item);
2852
- }
2853
- if (typeof arrOrStr === "string") {
2854
- return arrOrStr.includes(String(item ?? ""));
2302
+ function validateDeclarativeHookExpression(expression, surface, path) {
2303
+ try {
2304
+ const invalidRoots = getInvalidRoots(expression, SURFACE_ROOTS[surface]);
2305
+ if (invalidRoots.length === 0) return [];
2306
+ return [
2307
+ {
2308
+ path,
2309
+ surface,
2310
+ expression,
2311
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${SURFACE_ROOTS[surface].map((root) => `"${root}"`).join(", ")}`
2312
+ }
2313
+ ];
2314
+ } catch (error) {
2315
+ return [
2316
+ {
2317
+ path,
2318
+ surface,
2319
+ expression,
2320
+ message: `has invalid syntax: ${error.message}`
2321
+ }
2322
+ ];
2855
2323
  }
2856
- return false;
2857
- }
2858
- function join(arr, separator = ", ") {
2859
- if (!Array.isArray(arr)) return "";
2860
- return arr.filter((item) => item !== void 0 && item !== null).join(String(separator));
2861
- }
2862
- function first(arr) {
2863
- if (Array.isArray(arr)) return arr[0] ?? null;
2864
- return null;
2865
- }
2866
- function last(arr) {
2867
- if (Array.isArray(arr)) return arr[arr.length - 1] ?? null;
2868
- return null;
2869
2324
  }
2870
- function compact(arr) {
2871
- if (!Array.isArray(arr)) return [];
2872
- return arr.filter((item) => item !== void 0 && item !== null && item !== "");
2325
+ function validateDeclarativeAccessExpression(expression, surface, path) {
2326
+ try {
2327
+ const invalidRoots = getInvalidRoots(expression, ACCESS_ROOTS);
2328
+ if (invalidRoots.length === 0) return [];
2329
+ return [
2330
+ {
2331
+ path,
2332
+ surface,
2333
+ expression,
2334
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${ACCESS_ROOTS.map((root) => `"${root}"`).join(", ")}`
2335
+ }
2336
+ ];
2337
+ } catch (error) {
2338
+ return [
2339
+ {
2340
+ path,
2341
+ surface,
2342
+ expression,
2343
+ message: `has invalid syntax: ${error.message}`
2344
+ }
2345
+ ];
2346
+ }
2873
2347
  }
2874
- function unique(arr) {
2875
- if (!Array.isArray(arr)) return [];
2876
- return Array.from(new Set(arr));
2348
+ function validateAdminConditionExpression(expression, path, allowedRoots) {
2349
+ try {
2350
+ const invalidRoots = getInvalidRoots(expression, allowedRoots);
2351
+ if (invalidRoots.length === 0) return [];
2352
+ return [
2353
+ {
2354
+ path,
2355
+ surface: "field.admin.condition",
2356
+ expression,
2357
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
2358
+ }
2359
+ ];
2360
+ } catch (error) {
2361
+ return [
2362
+ {
2363
+ path,
2364
+ surface: "field.admin.condition",
2365
+ expression,
2366
+ message: `has invalid syntax: ${error.message}`
2367
+ }
2368
+ ];
2369
+ }
2877
2370
  }
2878
- function length(val) {
2879
- if (Array.isArray(val) || typeof val === "string") return val.length;
2880
- if (val && typeof val === "object") return Object.keys(val).length;
2881
- return 0;
2371
+ function validatePreviewUrlExpression(expression, path, allowedRoots) {
2372
+ try {
2373
+ const invalidRoots = getInvalidRoots(expression, allowedRoots);
2374
+ if (invalidRoots.length === 0) return [];
2375
+ return [
2376
+ {
2377
+ path,
2378
+ surface: "collection.admin.previewUrl",
2379
+ expression,
2380
+ message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
2381
+ }
2382
+ ];
2383
+ } catch (error) {
2384
+ return [
2385
+ {
2386
+ path,
2387
+ surface: "collection.admin.previewUrl",
2388
+ expression,
2389
+ message: `has invalid syntax: ${error.message}`
2390
+ }
2391
+ ];
2392
+ }
2882
2393
  }
2883
- function round(val, decimals = 0) {
2884
- const num = Number(val);
2885
- if (Number.isNaN(num)) return 0;
2886
- const dec = typeof decimals === "number" ? decimals : Number(decimals) || 0;
2887
- const factor = Math.pow(10, dec);
2888
- return Math.round(num * factor) / factor;
2394
+ function assertValidDeclarativeHookExpression(expression, surface, path) {
2395
+ const issues = validateDeclarativeHookExpression(expression, surface, path);
2396
+ if (issues.length === 0) return;
2397
+ throw new Error(`Invalid declarative hook at ${path}: ${issues[0].message}`);
2889
2398
  }
2890
- function clamp(val, minVal, maxVal) {
2891
- const num = Number(val) || 0;
2892
- const min2 = Number(minVal) || 0;
2893
- const max2 = Number(maxVal) || 0;
2894
- return Math.min(Math.max(num, min2), max2);
2399
+ function assertValidDeclarativeHookResult(result, surface, path) {
2400
+ if (isPlainObject(result)) return;
2401
+ throw new Error(`Invalid declarative hook result at ${path}: ${surface} expressions must return an object`);
2895
2402
  }
2896
- function ceil(val) {
2897
- return Math.ceil(Number(val) || 0);
2403
+ function collectFieldIssues(fields, path, issues) {
2404
+ fields.forEach((field, index) => {
2405
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2406
+ field.hooks?.beforeChange?.forEach((hook, hookIndex) => {
2407
+ if (typeof hook !== "string") return;
2408
+ issues.push(
2409
+ ...validateDeclarativeHookExpression(
2410
+ hook,
2411
+ "field.beforeChange",
2412
+ `${fieldPath}.hooks.beforeChange[${hookIndex}]`
2413
+ )
2414
+ );
2415
+ });
2416
+ field.hooks?.afterRead?.forEach((hook, hookIndex) => {
2417
+ if (typeof hook !== "string") return;
2418
+ issues.push({
2419
+ path: `${fieldPath}.hooks.afterRead[${hookIndex}]`,
2420
+ surface: "unsupported",
2421
+ expression: hook,
2422
+ message: "declarative strings are not supported on field hooks.afterRead"
2423
+ });
2424
+ });
2425
+ if (typeof field.admin?.hooks?.onChange === "string") {
2426
+ issues.push(
2427
+ ...validateDeclarativeHookExpression(
2428
+ field.admin.hooks.onChange,
2429
+ "field.admin.onChange",
2430
+ `${fieldPath}.admin.hooks.onChange`
2431
+ )
2432
+ );
2433
+ }
2434
+ if (typeof field.admin?.hooks?.options === "string") {
2435
+ issues.push({
2436
+ path: `${fieldPath}.admin.hooks.options`,
2437
+ surface: "unsupported",
2438
+ expression: field.admin.hooks.options,
2439
+ message: "declarative strings are not supported on field admin.hooks.options"
2440
+ });
2441
+ }
2442
+ if (Array.isArray(field.fields)) {
2443
+ collectFieldIssues(field.fields, fieldPath, issues);
2444
+ }
2445
+ if (Array.isArray(field.blocks)) {
2446
+ field.blocks.forEach((block, blockIndex) => {
2447
+ if (!Array.isArray(block.fields)) return;
2448
+ collectFieldIssues(block.fields, `${fieldPath}.${blockSegment(block, blockIndex)}`, issues);
2449
+ });
2450
+ }
2451
+ });
2898
2452
  }
2899
- function floor(val) {
2900
- return Math.floor(Number(val) || 0);
2453
+ function collectFieldNames(fields) {
2454
+ return fields.flatMap(
2455
+ (field) => typeof field.name === "string" && field.name.length > 0 ? [field.name] : []
2456
+ );
2901
2457
  }
2902
- function abs(val) {
2903
- return Math.abs(Number(val) || 0);
2458
+ function collectAdminConditionIssues(fields, path, rootFieldNames, issues) {
2459
+ const scopeFieldNames = collectFieldNames(fields);
2460
+ const allowedRoots = [.../* @__PURE__ */ new Set([...ADMIN_CONDITION_RESERVED_ROOTS, ...rootFieldNames, ...scopeFieldNames])];
2461
+ fields.forEach((field, index) => {
2462
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2463
+ if (typeof field.admin?.condition === "string") {
2464
+ issues.push(
2465
+ ...validateAdminConditionExpression(
2466
+ field.admin.condition,
2467
+ `${fieldPath}.admin.condition`,
2468
+ allowedRoots
2469
+ )
2470
+ );
2471
+ }
2472
+ if (Array.isArray(field.fields)) {
2473
+ collectAdminConditionIssues(field.fields, fieldPath, rootFieldNames, issues);
2474
+ }
2475
+ if (Array.isArray(field.blocks)) {
2476
+ field.blocks.forEach((block, blockIndex) => {
2477
+ if (!Array.isArray(block.fields)) return;
2478
+ collectAdminConditionIssues(
2479
+ block.fields,
2480
+ `${fieldPath}.${blockSegment(block, blockIndex)}`,
2481
+ rootFieldNames,
2482
+ issues
2483
+ );
2484
+ });
2485
+ }
2486
+ });
2904
2487
  }
2905
- function min(...args) {
2906
- const nums = args.flat().map((n) => Number(n) || 0);
2907
- return nums.length ? Math.min(...nums) : 0;
2488
+ function collectFieldAccessIssues(fields, path, issues) {
2489
+ fields.forEach((field, index) => {
2490
+ const fieldPath = `${path}.${fieldSegment(field, index)}`;
2491
+ if (typeof field.access?.read === "string") {
2492
+ issues.push(...validateDeclarativeAccessExpression(field.access.read, "field.access", `${fieldPath}.access.read`));
2493
+ }
2494
+ if (typeof field.access?.create === "string") {
2495
+ issues.push(
2496
+ ...validateDeclarativeAccessExpression(field.access.create, "field.access", `${fieldPath}.access.create`)
2497
+ );
2498
+ }
2499
+ if (typeof field.access?.update === "string") {
2500
+ issues.push(
2501
+ ...validateDeclarativeAccessExpression(field.access.update, "field.access", `${fieldPath}.access.update`)
2502
+ );
2503
+ }
2504
+ if (Array.isArray(field.fields)) {
2505
+ collectFieldAccessIssues(field.fields, fieldPath, issues);
2506
+ }
2507
+ if (Array.isArray(field.blocks)) {
2508
+ field.blocks.forEach((block, blockIndex) => {
2509
+ if (!Array.isArray(block.fields)) return;
2510
+ collectFieldAccessIssues(
2511
+ block.fields,
2512
+ `${fieldPath}.${blockSegment(block, blockIndex)}`,
2513
+ issues
2514
+ );
2515
+ });
2516
+ }
2517
+ });
2908
2518
  }
2909
- function max(...args) {
2910
- const nums = args.flat().map((n) => Number(n) || 0);
2911
- return nums.length ? Math.max(...nums) : 0;
2519
+ function collectHookIssues(hooks, scope, path, issues) {
2520
+ if (!hooks) return;
2521
+ const supported = {
2522
+ beforeRead: `${scope}.beforeRead`,
2523
+ afterRead: `${scope}.afterRead`,
2524
+ beforeChange: `${scope}.beforeChange`
2525
+ };
2526
+ for (const [key, value] of Object.entries(hooks)) {
2527
+ if (!Array.isArray(value)) continue;
2528
+ value.forEach((hook, index) => {
2529
+ if (typeof hook !== "string") return;
2530
+ const surface = supported[key];
2531
+ if (!surface) {
2532
+ issues.push({
2533
+ path: `${path}.${key}[${index}]`,
2534
+ surface: "unsupported",
2535
+ expression: hook,
2536
+ message: `declarative strings are not supported on ${scope} hooks.${key}`
2537
+ });
2538
+ return;
2539
+ }
2540
+ issues.push(...validateDeclarativeHookExpression(hook, surface, `${path}.${key}[${index}]`));
2541
+ });
2542
+ }
2912
2543
  }
2913
- function pow(base, exp) {
2914
- return Math.pow(Number(base) || 0, Number(exp) || 0);
2544
+ function collectDeclarativeHookValidationIssues(config) {
2545
+ const issues = [];
2546
+ config.blocks?.forEach((block, blockIndex) => {
2547
+ if (!Array.isArray(block.fields)) return;
2548
+ collectFieldIssues(
2549
+ block.fields,
2550
+ blockSegment(block, blockIndex),
2551
+ issues
2552
+ );
2553
+ });
2554
+ config.collections.forEach((collection, collectionIndex) => {
2555
+ const collectionPath = collectionSegment(collection, collectionIndex);
2556
+ collectHookIssues(
2557
+ collection.hooks,
2558
+ "collection",
2559
+ `${collectionPath}.hooks`,
2560
+ issues
2561
+ );
2562
+ collectFieldIssues(collection.fields, collectionPath, issues);
2563
+ });
2564
+ config.globals?.forEach((global, globalIndex) => {
2565
+ const globalPath = globalSegment(global, globalIndex);
2566
+ collectHookIssues(
2567
+ global.hooks,
2568
+ "global",
2569
+ `${globalPath}.hooks`,
2570
+ issues
2571
+ );
2572
+ collectFieldIssues(global.fields, globalPath, issues);
2573
+ });
2574
+ return issues;
2915
2575
  }
2916
- function sqrt(val) {
2917
- return Math.sqrt(Number(val) || 0);
2576
+ function collectDeclarativeAccessValidationIssues(config) {
2577
+ const issues = [];
2578
+ Object.entries(config.accessPolicies ?? {}).forEach(([name, value]) => {
2579
+ if (typeof value !== "string") return;
2580
+ issues.push(
2581
+ ...validateDeclarativeAccessExpression(value, "config.accessPolicy", `accessPolicies.${name}`)
2582
+ );
2583
+ });
2584
+ config.blocks?.forEach((block, blockIndex) => {
2585
+ if (!Array.isArray(block.fields)) return;
2586
+ collectFieldAccessIssues(
2587
+ block.fields,
2588
+ blockSegment(block, blockIndex),
2589
+ issues
2590
+ );
2591
+ });
2592
+ config.collections.forEach((collection, collectionIndex) => {
2593
+ const collectionPath = collectionSegment(collection, collectionIndex);
2594
+ if (typeof collection.access?.read === "string") {
2595
+ issues.push(
2596
+ ...validateDeclarativeAccessExpression(
2597
+ collection.access.read,
2598
+ "collection.access",
2599
+ `${collectionPath}.access.read`
2600
+ )
2601
+ );
2602
+ }
2603
+ if (typeof collection.access?.create === "string") {
2604
+ issues.push(
2605
+ ...validateDeclarativeAccessExpression(
2606
+ collection.access.create,
2607
+ "collection.access",
2608
+ `${collectionPath}.access.create`
2609
+ )
2610
+ );
2611
+ }
2612
+ if (typeof collection.access?.update === "string") {
2613
+ issues.push(
2614
+ ...validateDeclarativeAccessExpression(
2615
+ collection.access.update,
2616
+ "collection.access",
2617
+ `${collectionPath}.access.update`
2618
+ )
2619
+ );
2620
+ }
2621
+ if (typeof collection.access?.delete === "string") {
2622
+ issues.push(
2623
+ ...validateDeclarativeAccessExpression(
2624
+ collection.access.delete,
2625
+ "collection.access",
2626
+ `${collectionPath}.access.delete`
2627
+ )
2628
+ );
2629
+ }
2630
+ if (typeof collection.access?.readAudit === "string") {
2631
+ issues.push(
2632
+ ...validateDeclarativeAccessExpression(
2633
+ collection.access.readAudit,
2634
+ "collection.access",
2635
+ `${collectionPath}.access.readAudit`
2636
+ )
2637
+ );
2638
+ }
2639
+ collectFieldAccessIssues(collection.fields, collectionPath, issues);
2640
+ });
2641
+ config.globals?.forEach((global, globalIndex) => {
2642
+ const globalPath = globalSegment(global, globalIndex);
2643
+ if (typeof global.access?.read === "string") {
2644
+ issues.push(
2645
+ ...validateDeclarativeAccessExpression(global.access.read, "global.access", `${globalPath}.access.read`)
2646
+ );
2647
+ }
2648
+ if (typeof global.access?.update === "string") {
2649
+ issues.push(
2650
+ ...validateDeclarativeAccessExpression(
2651
+ global.access.update,
2652
+ "global.access",
2653
+ `${globalPath}.access.update`
2654
+ )
2655
+ );
2656
+ }
2657
+ collectFieldAccessIssues(global.fields, globalPath, issues);
2658
+ });
2659
+ return issues;
2918
2660
  }
2919
- function sum(arr) {
2920
- if (!Array.isArray(arr)) return Number(arr) || 0;
2921
- return arr.reduce((acc, val) => acc + (Number(val) || 0), 0);
2661
+ function collectAdminConditionValidationIssues(config) {
2662
+ const issues = [];
2663
+ config.blocks?.forEach((block, blockIndex) => {
2664
+ if (!Array.isArray(block.fields)) return;
2665
+ collectAdminConditionIssues(
2666
+ block.fields,
2667
+ blockSegment(block, blockIndex),
2668
+ collectFieldNames(block.fields),
2669
+ issues
2670
+ );
2671
+ });
2672
+ config.collections.forEach((collection, collectionIndex) => {
2673
+ const collectionPath = collectionSegment(collection, collectionIndex);
2674
+ const rootFieldNames = collectFieldNames(collection.fields);
2675
+ collectAdminConditionIssues(collection.fields, collectionPath, rootFieldNames, issues);
2676
+ });
2677
+ config.globals?.forEach((global, globalIndex) => {
2678
+ const globalPath = globalSegment(global, globalIndex);
2679
+ const rootFieldNames = collectFieldNames(global.fields);
2680
+ collectAdminConditionIssues(global.fields, globalPath, rootFieldNames, issues);
2681
+ });
2682
+ return issues;
2922
2683
  }
2923
- function count(val) {
2924
- if (Array.isArray(val)) return val.length;
2925
- if (typeof val === "string") return val.length;
2926
- if (val !== void 0 && val !== null && val !== false) return 1;
2927
- return 0;
2684
+ function collectPreviewUrlValidationIssues(config) {
2685
+ const issues = [];
2686
+ config.collections.forEach((collection, collectionIndex) => {
2687
+ if (typeof collection.admin?.previewUrl !== "string") return;
2688
+ const collectionPath = collectionSegment(collection, collectionIndex);
2689
+ const allowedRoots = [
2690
+ .../* @__PURE__ */ new Set([...PREVIEW_URL_RESERVED_ROOTS, ...collectFieldNames(collection.fields)])
2691
+ ];
2692
+ issues.push(
2693
+ ...validatePreviewUrlExpression(
2694
+ collection.admin.previewUrl,
2695
+ `${collectionPath}.admin.previewUrl`,
2696
+ allowedRoots
2697
+ )
2698
+ );
2699
+ });
2700
+ return issues;
2928
2701
  }
2929
- function defaultValue(val, fallback) {
2930
- if (val === void 0 || val === null || val === "") return fallback;
2931
- return val;
2702
+ function assertValidAdminConditionsInConfig(config, source = "config") {
2703
+ const diagnostics = collectAdminConditionValidationIssues(config).map((issue) => ({
2704
+ severity: "error",
2705
+ source: "adminCondition",
2706
+ path: issue.path,
2707
+ surface: issue.surface,
2708
+ message: issue.message,
2709
+ expression: issue.expression
2710
+ }));
2711
+ if (diagnostics.length === 0) return;
2712
+ throw new ConfigValidationError(source, diagnostics);
2932
2713
  }
2933
- function coalesce(...args) {
2934
- for (const arg of args) {
2935
- if (arg !== void 0 && arg !== null && arg !== "") return arg;
2936
- }
2937
- return null;
2714
+ function assertValidPreviewUrlsInConfig(config, source = "config") {
2715
+ const diagnostics = collectPreviewUrlValidationIssues(config).map((issue) => ({
2716
+ severity: "error",
2717
+ source: "previewUrl",
2718
+ path: issue.path,
2719
+ surface: issue.surface,
2720
+ message: issue.message,
2721
+ expression: issue.expression
2722
+ }));
2723
+ if (diagnostics.length === 0) return;
2724
+ throw new ConfigValidationError(source, diagnostics);
2938
2725
  }
2939
- function isEmpty(val) {
2940
- if (val === void 0 || val === null || val === "") return true;
2941
- if (Array.isArray(val)) return val.length === 0;
2942
- if (typeof val === "object") return Object.keys(val).length === 0;
2943
- return false;
2726
+ function collectConfigDiagnostics(config) {
2727
+ const hookIssues = collectDeclarativeHookValidationIssues(config).map((issue) => ({
2728
+ severity: "error",
2729
+ source: "declarativeHook",
2730
+ path: issue.path,
2731
+ surface: issue.surface,
2732
+ message: issue.message,
2733
+ expression: issue.expression
2734
+ }));
2735
+ const accessIssues = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
2736
+ severity: "error",
2737
+ source: "declarativeAccess",
2738
+ path: issue.path,
2739
+ surface: issue.surface,
2740
+ message: issue.message,
2741
+ expression: issue.expression
2742
+ }));
2743
+ const adminConditionIssues = collectAdminConditionValidationIssues(config).map((issue) => ({
2744
+ severity: "error",
2745
+ source: "adminCondition",
2746
+ path: issue.path,
2747
+ surface: issue.surface,
2748
+ message: issue.message,
2749
+ expression: issue.expression
2750
+ }));
2751
+ const previewUrlIssues = collectPreviewUrlValidationIssues(config).map((issue) => ({
2752
+ severity: "error",
2753
+ source: "previewUrl",
2754
+ path: issue.path,
2755
+ surface: issue.surface,
2756
+ message: issue.message,
2757
+ expression: issue.expression
2758
+ }));
2759
+ return [...hookIssues, ...accessIssues, ...adminConditionIssues, ...previewUrlIssues];
2760
+ }
2761
+ function formatConfigDiagnostics(source, diagnostics, options = {}) {
2762
+ const color = options.color ?? false;
2763
+ const header = `${colorize("Config validation failed", 31, color)} in ${colorize(source, 36, color)}`;
2764
+ 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)}`;
2765
+ const lines = diagnostics.flatMap((issue, index) => [
2766
+ `${colorize(`${index + 1}.`, 31, color)} ${colorize(issue.path, 1, color)}`,
2767
+ ` ${issue.message}`,
2768
+ ` ${colorize("surface:", 90, color)} ${issue.surface}`
2769
+ ]);
2770
+ return `${header}
2771
+ ${lines.join("\n")}
2772
+
2773
+ ${docLink}`;
2774
+ }
2775
+ var ConfigValidationError = class extends Error {
2776
+ diagnostics;
2777
+ source;
2778
+ constructor(source, diagnostics) {
2779
+ super(formatConfigDiagnostics(source, diagnostics));
2780
+ this.name = "ConfigValidationError";
2781
+ this.source = source;
2782
+ this.diagnostics = diagnostics;
2783
+ }
2784
+ };
2785
+ function isConfigValidationError(error) {
2786
+ return error instanceof ConfigValidationError;
2787
+ }
2788
+ function assertValidDeclarativeHooksInConfig(config, source = "config") {
2789
+ const diagnostics = collectDeclarativeHookValidationIssues(config).map((issue) => ({
2790
+ severity: "error",
2791
+ source: "declarativeHook",
2792
+ path: issue.path,
2793
+ surface: issue.surface,
2794
+ message: issue.message,
2795
+ expression: issue.expression
2796
+ }));
2797
+ if (diagnostics.length === 0) return;
2798
+ throw new ConfigValidationError(source, diagnostics);
2799
+ }
2800
+ function assertValidDeclarativeAccessInConfig(config, source = "config") {
2801
+ const diagnostics = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
2802
+ severity: "error",
2803
+ source: "declarativeAccess",
2804
+ path: issue.path,
2805
+ surface: issue.surface,
2806
+ message: issue.message,
2807
+ expression: issue.expression
2808
+ }));
2809
+ if (diagnostics.length === 0) return;
2810
+ throw new ConfigValidationError(source, diagnostics);
2811
+ }
2812
+
2813
+ // src/utils/hooks.ts
2814
+ async function runCollectionHooks(hooks, args, options = {}) {
2815
+ if (!hooks || hooks.length === 0) {
2816
+ return args.data ?? args.doc ?? args.query ?? void 0;
2817
+ }
2818
+ const payloadKey = args.data !== void 0 ? "data" : args.doc !== void 0 ? "doc" : "query" in args ? "query" : void 0;
2819
+ let currentPayload = payloadKey === void 0 ? void 0 : args[payloadKey];
2820
+ for (const hook of hooks) {
2821
+ try {
2822
+ const hookArgs = {
2823
+ ...args,
2824
+ ...payloadKey !== void 0 ? { [payloadKey]: currentPayload } : {}
2825
+ };
2826
+ const result = typeof hook === "string" ? await (() => {
2827
+ if (options.surface) {
2828
+ assertValidDeclarativeHookExpression(
2829
+ hook,
2830
+ options.surface,
2831
+ options.path ?? options.surface
2832
+ );
2833
+ }
2834
+ return import_jexl3.default.eval(hook, hookArgs);
2835
+ })() : await hook(hookArgs);
2836
+ if (result !== void 0) {
2837
+ if (typeof hook === "string" && options.surface) {
2838
+ assertValidDeclarativeHookResult(
2839
+ result,
2840
+ options.surface,
2841
+ options.path ?? options.surface
2842
+ );
2843
+ }
2844
+ currentPayload = typeof hook === "string" && currentPayload && typeof currentPayload === "object" && !Array.isArray(currentPayload) && result && typeof result === "object" && !Array.isArray(result) ? {
2845
+ ...currentPayload,
2846
+ ...result
2847
+ } : result;
2848
+ }
2849
+ } catch (err) {
2850
+ if (options.isolated) {
2851
+ console.error("Side-effect hook failed after a successful write", err);
2852
+ } else {
2853
+ throw err;
2854
+ }
2855
+ }
2856
+ }
2857
+ return currentPayload;
2858
+ }
2859
+ async function executeFieldBeforeChange(fields, data, originalDoc, user, db, pathPrefix = "") {
2860
+ if (!data || typeof data !== "object") return data;
2861
+ const result = { ...data };
2862
+ for (const field of fields) {
2863
+ if (!field.name) continue;
2864
+ const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
2865
+ const value = result[field.name];
2866
+ const origValue = originalDoc?.[field.name];
2867
+ let updatedValue = value;
2868
+ if (field.hooks?.beforeChange) {
2869
+ for (const hook of field.hooks.beforeChange) {
2870
+ const hookArgs = {
2871
+ value: updatedValue,
2872
+ originalDoc: originalDoc ?? void 0,
2873
+ data: result,
2874
+ user,
2875
+ db
2876
+ };
2877
+ updatedValue = typeof hook === "string" ? await (() => {
2878
+ assertValidDeclarativeHookExpression(
2879
+ hook,
2880
+ "field.beforeChange",
2881
+ fieldPath
2882
+ );
2883
+ return import_jexl3.default.eval(hook, hookArgs);
2884
+ })() : await hook(hookArgs);
2885
+ }
2886
+ result[field.name] = updatedValue;
2887
+ }
2888
+ if (updatedValue !== void 0 && updatedValue !== null) {
2889
+ if (field.type === "object" && field.fields) {
2890
+ result[field.name] = await executeFieldBeforeChange(
2891
+ field.fields,
2892
+ updatedValue,
2893
+ origValue,
2894
+ user,
2895
+ db,
2896
+ fieldPath
2897
+ );
2898
+ } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
2899
+ const arrayResult = [];
2900
+ for (let i = 0; i < updatedValue.length; i++) {
2901
+ const item = updatedValue[i];
2902
+ const origItem = Array.isArray(origValue) ? origValue[i] : null;
2903
+ arrayResult.push(
2904
+ await executeFieldBeforeChange(
2905
+ field.fields,
2906
+ item,
2907
+ origItem,
2908
+ user,
2909
+ db,
2910
+ fieldPath
2911
+ )
2912
+ );
2913
+ }
2914
+ result[field.name] = arrayResult;
2915
+ } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
2916
+ const blocksResult = [];
2917
+ for (let i = 0; i < updatedValue.length; i++) {
2918
+ const blockData = updatedValue[i];
2919
+ const origBlock = Array.isArray(origValue) ? origValue[i] : null;
2920
+ const blockConfig = field.blocks.find(
2921
+ (b) => b.slug === blockData.blockType
2922
+ );
2923
+ if (blockConfig) {
2924
+ blocksResult.push(
2925
+ await executeFieldBeforeChange(
2926
+ blockConfig.fields,
2927
+ blockData,
2928
+ origBlock,
2929
+ user,
2930
+ db,
2931
+ fieldPath
2932
+ )
2933
+ );
2934
+ } else {
2935
+ blocksResult.push(blockData);
2936
+ }
2937
+ }
2938
+ result[field.name] = blocksResult;
2939
+ }
2940
+ }
2941
+ }
2942
+ return result;
2943
+ }
2944
+ async function executeFieldAfterRead(fields, doc, user, db) {
2945
+ if (!doc || typeof doc !== "object") return doc;
2946
+ const result = { ...doc };
2947
+ for (const field of fields) {
2948
+ if (!field.name) continue;
2949
+ const value = result[field.name];
2950
+ let updatedValue = value;
2951
+ if (field.hooks?.afterRead) {
2952
+ for (const hook of field.hooks.afterRead) {
2953
+ const typedHook = hook;
2954
+ const hookArgs = {
2955
+ value: updatedValue,
2956
+ doc: result,
2957
+ user,
2958
+ db
2959
+ };
2960
+ updatedValue = await typedHook(hookArgs);
2961
+ }
2962
+ result[field.name] = updatedValue;
2963
+ }
2964
+ if (updatedValue !== void 0 && updatedValue !== null) {
2965
+ if (field.type === "object" && field.fields) {
2966
+ result[field.name] = await executeFieldAfterRead(
2967
+ field.fields,
2968
+ updatedValue,
2969
+ user,
2970
+ db
2971
+ );
2972
+ } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
2973
+ const arrayResult = [];
2974
+ for (const item of updatedValue) {
2975
+ arrayResult.push(
2976
+ await executeFieldAfterRead(
2977
+ field.fields,
2978
+ item,
2979
+ user,
2980
+ db
2981
+ )
2982
+ );
2983
+ }
2984
+ result[field.name] = arrayResult;
2985
+ } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
2986
+ const blocksResult = [];
2987
+ for (const blockData of updatedValue) {
2988
+ const typedBlock = blockData;
2989
+ const blockConfig = field.blocks.find(
2990
+ (b) => b.slug === typedBlock.blockType
2991
+ );
2992
+ if (blockConfig) {
2993
+ blocksResult.push(
2994
+ await executeFieldAfterRead(
2995
+ blockConfig.fields,
2996
+ typedBlock,
2997
+ user,
2998
+ db
2999
+ )
3000
+ );
3001
+ } else {
3002
+ blocksResult.push(blockData);
3003
+ }
3004
+ }
3005
+ result[field.name] = blocksResult;
3006
+ }
3007
+ }
3008
+ }
3009
+ return result;
3010
+ }
3011
+
3012
+ // src/trash.ts
3013
+ var TRASH_COLLECTION = "__trash";
3014
+ var TRASH_COLLECTION_CONFIG = {
3015
+ slug: TRASH_COLLECTION,
3016
+ labels: { singular: "Trash entry", plural: "Trash entries" },
3017
+ fields: [
3018
+ { name: "collection", type: "text", required: true, promoted: true },
3019
+ { name: "docId", type: "text", required: true, promoted: true },
3020
+ { name: "deletedAt", type: "number", required: true, promoted: true },
3021
+ // epoch ms
3022
+ { name: "purgeAt", type: "number", promoted: true },
3023
+ // epoch ms, null = never
3024
+ { name: "deletedBy", type: "text", promoted: true },
3025
+ { name: "title", type: "text" },
3026
+ // resolved useAsTitle, for listing
3027
+ { name: "snapshot", type: "json", required: true },
3028
+ // the raw stored document
3029
+ { name: "createdAt", type: "date" }
3030
+ // original doc timestamps live in snapshot
3031
+ ],
3032
+ indexes: [
3033
+ { fields: ["purgeAt"] },
3034
+ { fields: ["collection", "docId"] }
3035
+ ],
3036
+ access: {
3037
+ read: () => false,
3038
+ create: () => false,
3039
+ update: () => false,
3040
+ delete: () => false
3041
+ },
3042
+ admin: { hidden: true }
3043
+ };
3044
+ function getTrashEntryId(collection, docId) {
3045
+ return `${collection}:${docId}`;
3046
+ }
3047
+ function resolveTrashConfig(collection, appConfig) {
3048
+ const colTrash = collection.trash;
3049
+ const appTrash = appConfig?.trash;
3050
+ let enabled = true;
3051
+ if (colTrash === false) {
3052
+ enabled = false;
3053
+ } else if (colTrash === true || typeof colTrash === "object" && colTrash !== null) {
3054
+ enabled = true;
3055
+ } else if (appTrash?.enabled === false) {
3056
+ enabled = false;
3057
+ }
3058
+ if (!enabled) {
3059
+ return {
3060
+ enabled: false,
3061
+ retentionDays: null,
3062
+ allowPermanentDelete: true
3063
+ };
3064
+ }
3065
+ let retentionDays = 30;
3066
+ if (typeof colTrash === "object" && colTrash !== null && "retentionDays" in colTrash) {
3067
+ retentionDays = colTrash.retentionDays ?? null;
3068
+ } else if (appTrash?.retentionDays !== void 0) {
3069
+ retentionDays = appTrash.retentionDays ?? null;
3070
+ }
3071
+ const allowPermanentDelete = typeof colTrash === "object" && colTrash !== null && colTrash.allowPermanentDelete !== void 0 ? colTrash.allowPermanentDelete : appTrash?.allowPermanentDelete !== void 0 ? appTrash.allowPermanentDelete : true;
3072
+ return {
3073
+ enabled: true,
3074
+ retentionDays,
3075
+ allowPermanentDelete
3076
+ };
2944
3077
  }
2945
- function get(obj, path, fallback = null) {
2946
- if (obj === void 0 || obj === null || typeof obj !== "object") return fallback;
2947
- if (typeof path !== "string") return fallback;
2948
- const parts = path.split(".").filter(Boolean);
2949
- let curr = obj;
2950
- for (const part of parts) {
2951
- if (curr === void 0 || curr === null || typeof curr !== "object") return fallback;
2952
- curr = curr[part];
3078
+ function logTrashWarning(config, payload) {
3079
+ if (config?.logger && typeof config.logger.warn === "function") {
3080
+ config.logger.warn(payload);
3081
+ } else if (typeof console !== "undefined" && console.warn) {
3082
+ console.warn(`[dyrected] ${payload.msg}`);
2953
3083
  }
2954
- return curr === void 0 || curr === null ? fallback : curr;
2955
3084
  }
2956
- function registerJexlHelpers(jexlInstance = import_jexl.default) {
2957
- const helpers = {
2958
- // Strings
2959
- slugify,
2960
- lower,
2961
- upper,
2962
- trim,
2963
- capitalize,
2964
- truncate,
2965
- readingTime,
2966
- wordCount,
2967
- replace,
2968
- startsWith,
2969
- endsWith,
2970
- // Dates
2971
- now,
2972
- today,
2973
- formatDate,
2974
- addDays,
2975
- diffDays,
2976
- isPast,
2977
- isFuture,
2978
- // Arrays
2979
- includes,
2980
- join,
2981
- first,
2982
- last,
2983
- compact,
2984
- unique,
2985
- length,
2986
- // Math
2987
- round,
2988
- clamp,
2989
- ceil,
2990
- floor,
2991
- abs,
2992
- min,
2993
- max,
2994
- pow,
2995
- sqrt,
2996
- sum,
2997
- count,
2998
- // Objects & Logical
2999
- default: defaultValue,
3000
- coalesce,
3001
- isEmpty,
3002
- get
3003
- };
3004
- for (const [name, fn] of Object.entries(helpers)) {
3005
- try {
3006
- jexlInstance.addFunction(name, fn);
3007
- } catch {
3085
+ function assertValidTrashInConfig(config, source = "config") {
3086
+ if (config.trash) {
3087
+ const { retentionDays } = config.trash;
3088
+ if (retentionDays !== void 0 && retentionDays !== null) {
3089
+ if (!Number.isInteger(retentionDays) || retentionDays < 1) {
3090
+ throw new Error(
3091
+ `Invalid trash retentionDays in ${source}: must be an integer >= 1. Received ${retentionDays}.`
3092
+ );
3093
+ }
3094
+ if (retentionDays > 3650) {
3095
+ logTrashWarning(config, {
3096
+ msg: `Trash retentionDays in ${source} is unusually large (${retentionDays} days > 10 years). Check if value was provided in hours or seconds.`
3097
+ });
3098
+ }
3008
3099
  }
3009
- try {
3010
- jexlInstance.addTransform(name, fn);
3011
- } catch {
3100
+ }
3101
+ for (const col of config.collections || []) {
3102
+ if (col.slug === "trash") {
3103
+ throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3104
+ }
3105
+ const isSystem = col.slug.startsWith("__");
3106
+ if (isSystem && col.trash) {
3107
+ throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3108
+ }
3109
+ if (col.trash === false) {
3110
+ continue;
3111
+ }
3112
+ if (typeof col.trash === "object" && col.trash !== null) {
3113
+ const { retentionDays } = col.trash;
3114
+ if (retentionDays !== void 0 && retentionDays !== null) {
3115
+ if (!Number.isInteger(retentionDays) || retentionDays < 1) {
3116
+ throw new Error(
3117
+ `Invalid trash retentionDays for collection "${col.slug}" in ${source}: must be an integer >= 1. Received ${retentionDays}. Use trash: false for immediate hard delete.`
3118
+ );
3119
+ }
3120
+ if (retentionDays > 3650) {
3121
+ logTrashWarning(config, {
3122
+ msg: `Trash retentionDays for collection "${col.slug}" is unusually large (${retentionDays} days > 10 years). Check if value was provided in hours or seconds.`,
3123
+ collection: col.slug
3124
+ });
3125
+ }
3126
+ }
3012
3127
  }
3013
3128
  }
3014
- try {
3015
- jexlInstance.addBinaryOp("and", 20, (left, right) => Boolean(left && right));
3016
- jexlInstance.addBinaryOp("or", 10, (left, right) => Boolean(left || right));
3017
- jexlInstance.addBinaryOp("in", 30, (left, right) => {
3018
- if (Array.isArray(right)) return right.includes(left);
3019
- if (typeof right === "string") return right.includes(String(left ?? ""));
3020
- return false;
3021
- });
3022
- } catch {
3129
+ for (const glob of config.globals || []) {
3130
+ if (glob.slug === "trash") {
3131
+ throw new Error(`Global slug "trash" is reserved by Dyrected for the trash system.`);
3132
+ }
3023
3133
  }
3024
3134
  }
3025
- registerJexlHelpers(import_jexl.default);
3026
- async function evaluateJexl(expression, context = {}) {
3027
- if (!expression || typeof expression !== "string") return void 0;
3028
- try {
3029
- const evalContext = {
3030
- null: null,
3031
- undefined: void 0,
3032
- ...context,
3033
- math: Math,
3034
- Math
3035
- };
3036
- return await import_jexl.default.eval(expression, evalContext);
3037
- } catch (_err) {
3038
- return void 0;
3135
+ function resolveDocumentTitle(collection, doc) {
3136
+ if (!doc || typeof doc !== "object") return "";
3137
+ const titleField = collection.admin?.useAsTitle || "title";
3138
+ const val = doc[titleField];
3139
+ if (val !== void 0 && val !== null && String(val).trim().length > 0) {
3140
+ return String(val);
3141
+ }
3142
+ if (doc.name !== void 0 && doc.name !== null && String(doc.name).trim().length > 0) {
3143
+ return String(doc.name);
3144
+ }
3145
+ if (doc.id !== void 0 && doc.id !== null) {
3146
+ return String(doc.id);
3039
3147
  }
3148
+ return "";
3040
3149
  }
3041
- function evaluateJexlSync(expression, context = {}) {
3042
- if (!expression || typeof expression !== "string") return void 0;
3043
- try {
3044
- const evalContext = {
3045
- null: null,
3046
- undefined: void 0,
3047
- ...context,
3048
- math: Math,
3049
- Math
3050
- };
3051
- return import_jexl.default.evalSync(expression, evalContext);
3052
- } catch (_err) {
3053
- return void 0;
3150
+ function createTrashPurgeTask(config) {
3151
+ return {
3152
+ name: "dyrected:trash-purge",
3153
+ cron: config.trash?.purge?.cron ?? "0 3 * * *",
3154
+ run: async ({ db, logger, signal }) => {
3155
+ const batchSize = Math.max(1, config.trash?.purge?.batchSize ?? 200);
3156
+ while (true) {
3157
+ if (signal?.aborted) return;
3158
+ const now2 = Date.now();
3159
+ const due = await db.find({
3160
+ collection: TRASH_COLLECTION,
3161
+ where: {
3162
+ purgeAt: { lte: now2, not_equals: null }
3163
+ },
3164
+ limit: batchSize,
3165
+ sort: "purgeAt"
3166
+ });
3167
+ if (!due.docs.length) break;
3168
+ for (const entry of due.docs) {
3169
+ if (signal?.aborted) return;
3170
+ if (entry.purgeAt == null || typeof entry.purgeAt !== "number") continue;
3171
+ try {
3172
+ const colSlug = entry.collection;
3173
+ const colConfig = config.collections.find((c) => c.slug === colSlug);
3174
+ const snapshot = typeof entry.snapshot === "string" ? JSON.parse(entry.snapshot) : entry.snapshot;
3175
+ if (colConfig?.upload && config.storage && snapshot?.filename) {
3176
+ try {
3177
+ await config.storage.delete({ filename: snapshot.filename });
3178
+ if (snapshot.sizes && typeof snapshot.sizes === "object") {
3179
+ for (const size of Object.values(snapshot.sizes)) {
3180
+ if (size?.filename) {
3181
+ await config.storage.delete({ filename: size.filename });
3182
+ }
3183
+ }
3184
+ }
3185
+ } catch (storageErr) {
3186
+ logger.warn({ err: storageErr?.message || storageErr, docId: entry.docId, collection: colSlug }, "Failed to delete storage file on purge");
3187
+ }
3188
+ }
3189
+ if (colConfig?.hooks?.afterDelete) {
3190
+ await runCollectionHooks(
3191
+ colConfig.hooks.afterDelete,
3192
+ {
3193
+ id: entry.docId,
3194
+ doc: snapshot,
3195
+ user: void 0,
3196
+ req: {},
3197
+ db,
3198
+ mode: "permanent"
3199
+ },
3200
+ { isolated: true }
3201
+ );
3202
+ }
3203
+ if (colConfig?.audit) {
3204
+ try {
3205
+ await db.create({
3206
+ collection: "__audit",
3207
+ data: {
3208
+ collection: colSlug,
3209
+ documentId: entry.docId,
3210
+ operation: "purge",
3211
+ user: null,
3212
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3213
+ changes: JSON.stringify({
3214
+ before: snapshot,
3215
+ after: null
3216
+ })
3217
+ }
3218
+ });
3219
+ } catch (auditErr) {
3220
+ logger.warn({ err: auditErr?.message || auditErr, docId: entry.docId, collection: colSlug }, "Failed to write audit log on purge");
3221
+ }
3222
+ }
3223
+ await db.delete({ collection: TRASH_COLLECTION, id: entry.id });
3224
+ } catch (entryErr) {
3225
+ logger.error({ err: entryErr?.message || entryErr, entryId: entry.id }, "Error purging trash entry");
3226
+ }
3227
+ }
3228
+ if (due.docs.length < batchSize) break;
3229
+ }
3230
+ }
3231
+ };
3232
+ }
3233
+ async function softDeleteDocument(options) {
3234
+ const { db, config, collection: collectionSlug, id, deletedBy } = options;
3235
+ const colConfig = config.collections.find((c) => c.slug === collectionSlug);
3236
+ if (!colConfig) throw new Error(`Collection ${collectionSlug} not found`);
3237
+ const doc = await db.findOne({ collection: collectionSlug, id });
3238
+ if (!doc) throw new Error(`Document ${id} in ${collectionSlug} not found`);
3239
+ const resolvedTrash = resolveTrashConfig(colConfig, config);
3240
+ const deletedAt = Date.now();
3241
+ const purgeAt = resolvedTrash.retentionDays !== null && resolvedTrash.retentionDays !== void 0 ? deletedAt + resolvedTrash.retentionDays * 864e5 : null;
3242
+ const title = resolveDocumentTitle(colConfig, doc);
3243
+ const trashId = getTrashEntryId(collectionSlug, id);
3244
+ const trashData = {
3245
+ id: trashId,
3246
+ collection: collectionSlug,
3247
+ docId: id,
3248
+ deletedAt,
3249
+ purgeAt,
3250
+ deletedBy: deletedBy ?? null,
3251
+ title,
3252
+ snapshot: doc,
3253
+ createdAt: doc.createdAt ?? new Date(deletedAt).toISOString()
3254
+ };
3255
+ if (db.transaction) {
3256
+ await db.transaction(async (tx) => {
3257
+ await tx.create({ collection: TRASH_COLLECTION, data: trashData });
3258
+ await tx.delete({ collection: collectionSlug, id });
3259
+ });
3260
+ } else {
3261
+ await db.create({ collection: TRASH_COLLECTION, data: trashData });
3262
+ await db.delete({ collection: collectionSlug, id });
3263
+ }
3264
+ return { id: trashId, purgeAt };
3265
+ }
3266
+ async function restoreDocument(options) {
3267
+ const { db, config, collection: collectionSlug, trashId: param, overrides } = options;
3268
+ const colConfig = config.collections.find((c) => c.slug === collectionSlug);
3269
+ if (!colConfig) throw new Error(`Collection ${collectionSlug} not found`);
3270
+ const trashId = param.includes(":") ? param : getTrashEntryId(collectionSlug, param);
3271
+ const entry = await db.findOne({ collection: TRASH_COLLECTION, id: trashId }) || await db.findOne({ collection: TRASH_COLLECTION, id: param });
3272
+ if (!entry || entry.collection !== collectionSlug) {
3273
+ throw new Error(`Trash entry ${param} not found for collection ${collectionSlug}`);
3274
+ }
3275
+ const snapshot = typeof entry.snapshot === "string" ? JSON.parse(entry.snapshot) : entry.snapshot;
3276
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3277
+ const docToRestore = {
3278
+ ...snapshot,
3279
+ ...overrides || {},
3280
+ id: entry.docId,
3281
+ createdAt: snapshot.createdAt ?? entry.createdAt ?? now2,
3282
+ updatedAt: now2
3283
+ };
3284
+ if (db.transaction) {
3285
+ await db.transaction(async (tx) => {
3286
+ await tx.create({ collection: collectionSlug, data: docToRestore });
3287
+ await tx.delete({ collection: TRASH_COLLECTION, id: entry.id });
3288
+ });
3289
+ } else {
3290
+ await db.create({ collection: collectionSlug, data: docToRestore });
3291
+ await db.delete({ collection: TRASH_COLLECTION, id: entry.id });
3054
3292
  }
3293
+ return { restoredDoc: docToRestore };
3055
3294
  }
3056
- var BUILTIN_JEXL_HELPERS = [
3057
- // Strings
3058
- "slugify",
3059
- "lower",
3060
- "upper",
3061
- "trim",
3062
- "capitalize",
3063
- "truncate",
3064
- "readingTime",
3065
- "wordCount",
3066
- "replace",
3067
- "startsWith",
3068
- "endsWith",
3069
- // Dates
3070
- "now",
3071
- "today",
3072
- "formatDate",
3073
- "addDays",
3074
- "diffDays",
3075
- "isPast",
3076
- "isFuture",
3077
- // Arrays
3078
- "includes",
3079
- "join",
3080
- "first",
3081
- "last",
3082
- "compact",
3083
- "unique",
3084
- "length",
3085
- // Math
3086
- "round",
3087
- "clamp",
3088
- // Objects & Logical
3089
- "default",
3090
- "coalesce",
3091
- "isEmpty",
3092
- "get"
3093
- ];
3094
3295
 
3095
- // src/utils/declarative-hooks.ts
3096
- registerJexlHelpers(import_jexl2.default);
3097
- var SURFACE_ROOTS = {
3098
- "collection.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
3099
- "collection.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
3100
- "collection.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
3101
- "global.beforeRead": ["req", "user", "query", ...BUILTIN_JEXL_HELPERS],
3102
- "global.afterRead": ["req", "user", "doc", ...BUILTIN_JEXL_HELPERS],
3103
- "global.beforeChange": ["req", "user", "data", "doc", "operation", ...BUILTIN_JEXL_HELPERS],
3104
- "field.beforeChange": ["value", "data", "originalDoc", "user", ...BUILTIN_JEXL_HELPERS],
3105
- "field.admin.onChange": ["value", "siblingData", "data", ...BUILTIN_JEXL_HELPERS]
3296
+ // src/tasks.ts
3297
+ var TASK_LOCKS_COLLECTION = "__task_locks";
3298
+ var TASK_LOCKS_COLLECTION_CONFIG = {
3299
+ slug: TASK_LOCKS_COLLECTION,
3300
+ labels: { singular: "Task lock", plural: "Task locks" },
3301
+ fields: [
3302
+ { name: "name", type: "text", required: true },
3303
+ // Numeric and promoted so the lock compare-and-set is a real column comparison.
3304
+ { name: "lockedUntil", type: "number", required: true, promoted: true },
3305
+ { name: "lockedBy", type: "text" },
3306
+ { name: "lastScheduledFor", type: "date" },
3307
+ { name: "lastStartedAt", type: "date" },
3308
+ { name: "lastFinishedAt", type: "date" },
3309
+ { name: "lastStatus", type: "text" },
3310
+ { name: "lastError", type: "textarea" }
3311
+ ],
3312
+ access: {
3313
+ read: ({ user }) => !!user?.roles?.includes("admin"),
3314
+ create: () => false,
3315
+ update: () => false,
3316
+ delete: () => false
3317
+ },
3318
+ admin: { hidden: true }
3106
3319
  };
3107
- var ACCESS_ROOTS = ["user", "req", "doc", "data", "id", "config", ...BUILTIN_JEXL_HELPERS];
3108
- var ADMIN_CONDITION_RESERVED_ROOTS = ["data", "siblingData", "user", "id", ...BUILTIN_JEXL_HELPERS];
3109
- var PREVIEW_URL_RESERVED_ROOTS = ["siteUrl", "id", ...BUILTIN_JEXL_HELPERS];
3110
- var COMPILE_CACHE = /* @__PURE__ */ new Map();
3111
- function compileExpression(expression) {
3112
- const cached = COMPILE_CACHE.get(expression);
3113
- if (cached) return cached;
3114
- const compiled = import_jexl2.default.compile(expression);
3115
- const next = { ast: compiled._ast };
3116
- COMPILE_CACHE.set(expression, next);
3117
- return next;
3118
- }
3119
- function isPlainObject(value) {
3120
- return !!value && typeof value === "object" && !Array.isArray(value);
3320
+ var DEFAULT_LOCK_TIMEOUT_SECONDS = 300;
3321
+ var DEFAULT_TICK_MS = 3e4;
3322
+ var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
3323
+ var DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
3324
+ function parseCronField(raw, min2, max2, names, nameOffset, label) {
3325
+ const values = /* @__PURE__ */ new Set();
3326
+ const toNumber = (token) => {
3327
+ const named = names?.indexOf(token.toLowerCase());
3328
+ if (named !== void 0 && named >= 0) return named + nameOffset;
3329
+ if (!/^\d+$/.test(token)) throw new Error(`Invalid cron ${label} "${token}".`);
3330
+ return Number(token);
3331
+ };
3332
+ for (const part of raw.split(",")) {
3333
+ const [rangePart, stepPart] = part.split("/");
3334
+ const step = stepPart === void 0 ? 1 : Number(stepPart);
3335
+ if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron ${label} step "${part}".`);
3336
+ let start;
3337
+ let end;
3338
+ if (rangePart === "*") {
3339
+ start = min2;
3340
+ end = max2;
3341
+ } else if (rangePart.includes("-")) {
3342
+ const [a, b] = rangePart.split("-");
3343
+ start = toNumber(a);
3344
+ end = toNumber(b);
3345
+ } else {
3346
+ start = toNumber(rangePart);
3347
+ end = stepPart === void 0 ? start : max2;
3348
+ }
3349
+ if (start < min2 || end > max2 || start > end) {
3350
+ throw new Error(`Cron ${label} "${part}" is out of range (${min2}-${max2}).`);
3351
+ }
3352
+ for (let v = start; v <= end; v += step) values.add(v);
3353
+ }
3354
+ return values;
3121
3355
  }
3122
- function quoteSegment(value) {
3123
- return `["${value.replace(/"/g, '\\"')}"]`;
3356
+ function parseCron(expression) {
3357
+ const fields = expression.trim().split(/\s+/);
3358
+ if (fields.length !== 5) {
3359
+ throw new Error(`Cron expression "${expression}" must have 5 fields (minute hour day-of-month month day-of-week).`);
3360
+ }
3361
+ const [min2, hour, dom, mon, dow] = fields;
3362
+ const daysOfWeek = parseCronField(dow, 0, 7, DAY_NAMES, 0, "day-of-week");
3363
+ if (daysOfWeek.has(7)) daysOfWeek.add(0);
3364
+ daysOfWeek.delete(7);
3365
+ return {
3366
+ minutes: parseCronField(min2, 0, 59, void 0, 0, "minute"),
3367
+ hours: parseCronField(hour, 0, 23, void 0, 0, "hour"),
3368
+ daysOfMonth: parseCronField(dom, 1, 31, void 0, 0, "day-of-month"),
3369
+ months: parseCronField(mon, 1, 12, MONTH_NAMES, 1, "month"),
3370
+ daysOfWeek,
3371
+ dayFieldsBothRestricted: dom !== "*" && dow !== "*"
3372
+ };
3124
3373
  }
3125
- function fieldSegment(field, index) {
3126
- return typeof field.name === "string" && field.name.length > 0 ? `fields${quoteSegment(field.name)}` : `fields[${index}]`;
3374
+ function dayMatches(schedule, date) {
3375
+ const domOk = schedule.daysOfMonth.has(date.getUTCDate());
3376
+ const dowOk = schedule.daysOfWeek.has(date.getUTCDay());
3377
+ return schedule.dayFieldsBothRestricted ? domOk || dowOk : domOk && dowOk;
3127
3378
  }
3128
- function blockSegment(block, index) {
3129
- return typeof block.slug === "string" && block.slug.length > 0 ? `blocks${quoteSegment(block.slug)}` : `blocks[${index}]`;
3379
+ function nextCronRun(expression, after) {
3380
+ const schedule = typeof expression === "string" ? parseCron(expression) : expression;
3381
+ const cursor = new Date(after.getTime());
3382
+ cursor.setUTCSeconds(0, 0);
3383
+ cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
3384
+ const limit = after.getTime() + 5 * 366 * 24 * 60 * 60 * 1e3;
3385
+ while (cursor.getTime() <= limit) {
3386
+ if (!schedule.months.has(cursor.getUTCMonth() + 1)) {
3387
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1, 1);
3388
+ cursor.setUTCHours(0, 0, 0, 0);
3389
+ continue;
3390
+ }
3391
+ if (!dayMatches(schedule, cursor)) {
3392
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
3393
+ cursor.setUTCHours(0, 0, 0, 0);
3394
+ continue;
3395
+ }
3396
+ if (!schedule.hours.has(cursor.getUTCHours())) {
3397
+ cursor.setUTCHours(cursor.getUTCHours() + 1, 0, 0, 0);
3398
+ continue;
3399
+ }
3400
+ if (!schedule.minutes.has(cursor.getUTCMinutes())) {
3401
+ cursor.setUTCMinutes(cursor.getUTCMinutes() + 1, 0, 0);
3402
+ continue;
3403
+ }
3404
+ return cursor;
3405
+ }
3406
+ throw new Error(`Cron expression "${typeof expression === "string" ? expression : "schedule"}" never matches.`);
3130
3407
  }
3131
- function collectionSegment(collection, index) {
3132
- return typeof collection.slug === "string" && collection.slug.length > 0 ? `collections${quoteSegment(collection.slug)}` : `collections[${index}]`;
3408
+ function defineTask(task) {
3409
+ if (!task.name || !task.name.trim()) throw new Error("A task needs a non-empty name.");
3410
+ if (task.cron !== void 0) parseCron(task.cron);
3411
+ if (task.lockTimeout !== void 0 && !(task.lockTimeout > 0)) {
3412
+ throw new Error(`Task "${task.name}" lockTimeout must be a positive number of seconds.`);
3413
+ }
3414
+ return task;
3133
3415
  }
3134
- function globalSegment(global, index) {
3135
- return typeof global.slug === "string" && global.slug.length > 0 ? `globals${quoteSegment(global.slug)}` : `globals[${index}]`;
3416
+ function resolveLogger(config, taskName) {
3417
+ const base = config.logger;
3418
+ if (base && typeof base.child === "function") return base.child({ component: "tasks", task: taskName });
3419
+ const prefix = `[dyrected/tasks] ${taskName}`;
3420
+ return {
3421
+ info: (obj, msg) => console.info(prefix, msg ?? "", obj),
3422
+ warn: (obj, msg) => console.warn(prefix, msg ?? "", obj),
3423
+ error: (obj, msg) => console.error(prefix, msg ?? "", obj)
3424
+ };
3136
3425
  }
3137
- function colorize(value, code, enabled) {
3138
- return enabled ? `\x1B[${code}m${value}\x1B[0m` : value;
3426
+ function floorToMinute(date) {
3427
+ const d = new Date(date.getTime());
3428
+ d.setUTCSeconds(0, 0);
3429
+ return d;
3139
3430
  }
3140
- function collectRootIdentifiers(node, roots, seen) {
3141
- if (!node || typeof node !== "object" || seen.has(node)) return;
3142
- seen.add(node);
3143
- const candidate = node;
3144
- if (candidate.type === "Identifier") {
3145
- if (candidate.from) {
3146
- collectRootIdentifiers(candidate.from, roots, seen);
3147
- return;
3431
+ function createTaskRunner(config) {
3432
+ const db = config.db;
3433
+ if (!db) throw new Error("createTaskRunner requires config.db.");
3434
+ const tasks = /* @__PURE__ */ new Map();
3435
+ for (const task of config.tasks ?? []) {
3436
+ if (tasks.has(task.name)) throw new Error(`Duplicate task name "${task.name}".`);
3437
+ tasks.set(task.name, { ...task, schedule: task.cron ? parseCron(task.cron) : void 0 });
3438
+ }
3439
+ const hasRetention = (config.collections ?? []).some((col) => {
3440
+ const resolved = resolveTrashConfig(col, config);
3441
+ return resolved.enabled && resolved.retentionDays !== null && resolved.retentionDays > 0;
3442
+ });
3443
+ if (hasRetention && !tasks.has("dyrected:trash-purge")) {
3444
+ const purgeTask = createTrashPurgeTask(config);
3445
+ tasks.set(purgeTask.name, { ...purgeTask, schedule: purgeTask.cron ? parseCron(purgeTask.cron) : void 0 });
3446
+ }
3447
+ const instanceId = globalThis.crypto?.randomUUID?.() ?? `inst_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3448
+ let timer;
3449
+ let inFlight = Promise.resolve();
3450
+ let ticking = false;
3451
+ let ready;
3452
+ function ensureReady() {
3453
+ ready ??= Promise.resolve(db.sync?.([TASK_LOCKS_COLLECTION_CONFIG], [])).then(() => void 0);
3454
+ ready.catch(() => {
3455
+ ready = void 0;
3456
+ });
3457
+ return ready;
3458
+ }
3459
+ async function ensureLockRow(name, now2) {
3460
+ await ensureReady();
3461
+ const existing = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
3462
+ if (existing) return existing;
3463
+ try {
3464
+ return await db.create({
3465
+ collection: TASK_LOCKS_COLLECTION,
3466
+ data: {
3467
+ id: name,
3468
+ name,
3469
+ lockedUntil: 0,
3470
+ lockedBy: null,
3471
+ lastScheduledFor: floorToMinute(now2).toISOString(),
3472
+ lastStartedAt: null,
3473
+ lastFinishedAt: null,
3474
+ lastStatus: null,
3475
+ lastError: null
3476
+ }
3477
+ });
3478
+ } catch (error) {
3479
+ const created = await db.findOne({ collection: TASK_LOCKS_COLLECTION, id: name });
3480
+ if (created) return created;
3481
+ throw error;
3148
3482
  }
3149
- if (typeof candidate.value === "string" && candidate.value !== "null") {
3150
- roots.add(candidate.value);
3483
+ }
3484
+ async function execute(task, row, scheduledFor) {
3485
+ const started = Date.now();
3486
+ const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
3487
+ const token = `${instanceId}:${started}`;
3488
+ const where = {
3489
+ id: { equals: task.name },
3490
+ lockedUntil: { less_than: started }
3491
+ };
3492
+ if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
3493
+ const claimed = await db.update({
3494
+ collection: TASK_LOCKS_COLLECTION,
3495
+ where,
3496
+ data: {
3497
+ lockedUntil: started + lockMs,
3498
+ lockedBy: token,
3499
+ lastStartedAt: new Date(started).toISOString(),
3500
+ ...scheduledFor ? { lastScheduledFor: scheduledFor.toISOString() } : {}
3501
+ }
3502
+ });
3503
+ if (claimed.affectedRows !== 1) {
3504
+ return { name: task.name, status: "skipped", reason: "locked", durationMs: Date.now() - started };
3151
3505
  }
3152
- return;
3506
+ const logger = resolveLogger(config, task.name);
3507
+ const controller = new AbortController();
3508
+ let timeout;
3509
+ let status = "completed";
3510
+ let errorMessage;
3511
+ try {
3512
+ await Promise.race([
3513
+ Promise.resolve(task.run({ db, logger, signal: controller.signal, scheduledFor })),
3514
+ new Promise((_, reject) => {
3515
+ timeout = setTimeout(() => {
3516
+ reject(new Error(`Task exceeded its lockTimeout of ${lockMs / 1e3}s.`));
3517
+ controller.abort();
3518
+ }, lockMs);
3519
+ })
3520
+ ]);
3521
+ } catch (error) {
3522
+ status = "failed";
3523
+ errorMessage = error instanceof Error ? error.message : String(error);
3524
+ logger.error({ err: error }, "Task failed");
3525
+ } finally {
3526
+ if (timeout) clearTimeout(timeout);
3527
+ }
3528
+ try {
3529
+ await db.update({
3530
+ collection: TASK_LOCKS_COLLECTION,
3531
+ where: { id: { equals: task.name }, lockedBy: { equals: token } },
3532
+ data: {
3533
+ lockedUntil: 0,
3534
+ lockedBy: null,
3535
+ lastFinishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3536
+ lastStatus: status,
3537
+ lastError: errorMessage ?? null
3538
+ }
3539
+ });
3540
+ } catch (error) {
3541
+ logger.warn({ err: error }, "Could not release task lock; it will expire on its own");
3542
+ }
3543
+ return { name: task.name, status, error: errorMessage, durationMs: Date.now() - started };
3153
3544
  }
3154
- if (Array.isArray(node)) {
3155
- for (const entry of node) collectRootIdentifiers(entry, roots, seen);
3156
- return;
3545
+ function track(work) {
3546
+ inFlight = inFlight.then(() => work).catch(() => void 0);
3547
+ return work;
3157
3548
  }
3158
- for (const value of Object.values(node)) {
3159
- if (Array.isArray(value)) {
3160
- for (const entry of value) collectRootIdentifiers(entry, roots, seen);
3161
- continue;
3162
- }
3163
- if (value && typeof value === "object") {
3164
- collectRootIdentifiers(value, roots, seen);
3549
+ const runner = {
3550
+ async runTask(name) {
3551
+ const task = tasks.get(name);
3552
+ if (!task) throw new Error(`Unknown task "${name}".`);
3553
+ const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
3554
+ return track(execute(task, row, void 0));
3555
+ },
3556
+ async runDue(now2 = /* @__PURE__ */ new Date()) {
3557
+ const results = [];
3558
+ for (const task of tasks.values()) {
3559
+ if (!task.schedule) continue;
3560
+ const row = await ensureLockRow(task.name, now2);
3561
+ const reference = new Date(String(row.lastScheduledFor));
3562
+ const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
3563
+ if (!due) {
3564
+ results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
3565
+ continue;
3566
+ }
3567
+ results.push(await track(execute(task, row, floorToMinute(now2))));
3568
+ }
3569
+ return results;
3570
+ },
3571
+ start(options) {
3572
+ if (timer) return;
3573
+ const intervalMs = options?.intervalMs ?? DEFAULT_TICK_MS;
3574
+ const tick = () => {
3575
+ if (ticking) return;
3576
+ ticking = true;
3577
+ runner.runDue().catch((error) => resolveLogger(config, "runner").error({ err: error }, "Task tick failed")).finally(() => {
3578
+ ticking = false;
3579
+ });
3580
+ };
3581
+ timer = setInterval(tick, intervalMs);
3582
+ timer.unref?.();
3583
+ setTimeout(tick, 0);
3584
+ },
3585
+ async stop() {
3586
+ if (timer) clearInterval(timer);
3587
+ timer = void 0;
3588
+ await inFlight;
3589
+ },
3590
+ getTasks() {
3591
+ return Array.from(tasks.values()).map((t) => ({ name: t.name, cron: t.cron }));
3165
3592
  }
3166
- }
3167
- }
3168
- function getInvalidRoots(expression, allowedRoots) {
3169
- const { ast } = compileExpression(expression);
3170
- const roots = /* @__PURE__ */ new Set();
3171
- collectRootIdentifiers(ast, roots, /* @__PURE__ */ new Set());
3172
- const allowed = new Set(allowedRoots);
3173
- return [...roots].filter((root) => !allowed.has(root));
3593
+ };
3594
+ return runner;
3174
3595
  }
3175
- function validateDeclarativeHookExpression(expression, surface, path) {
3176
- try {
3177
- const invalidRoots = getInvalidRoots(expression, SURFACE_ROOTS[surface]);
3178
- if (invalidRoots.length === 0) return [];
3179
- return [
3596
+
3597
+ // src/workflows.ts
3598
+ var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
3599
+ var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
3600
+ var EDITOR_CAPABILITIES = ["entry.edit", "entry.submit"];
3601
+ var PUBLISHER_CAPABILITIES = ["entry.edit", "entry.submit", "entry.publish", "entry.unpublish"];
3602
+ function definePublishingWorkflow(options = {}) {
3603
+ const editors = options.editors ?? ["editor"];
3604
+ const publishers = options.publishers ?? ["publisher", "admin"];
3605
+ return {
3606
+ initialState: "draft",
3607
+ draftState: "draft",
3608
+ states: [
3609
+ { name: "draft", label: "Draft", color: "neutral" },
3610
+ { name: "in_review", label: "In review", color: "warning" },
3611
+ { name: "published", label: "Published", color: "success", published: true }
3612
+ ],
3613
+ transitions: [
3180
3614
  {
3181
- path,
3182
- surface,
3183
- expression,
3184
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${SURFACE_ROOTS[surface].map((root) => `"${root}"`).join(", ")}`
3185
- }
3186
- ];
3187
- } catch (error) {
3188
- return [
3615
+ name: "submit",
3616
+ label: "Submit for review",
3617
+ from: "draft",
3618
+ to: "in_review",
3619
+ requiredCapabilities: ["entry.submit"]
3620
+ },
3189
3621
  {
3190
- path,
3191
- surface,
3192
- expression,
3193
- message: `has invalid syntax: ${error.message}`
3194
- }
3195
- ];
3196
- }
3197
- }
3198
- function validateDeclarativeAccessExpression(expression, surface, path) {
3199
- try {
3200
- const invalidRoots = getInvalidRoots(expression, ACCESS_ROOTS);
3201
- if (invalidRoots.length === 0) return [];
3202
- return [
3622
+ name: "publish",
3623
+ label: "Publish",
3624
+ from: "in_review",
3625
+ to: "published",
3626
+ requiredCapabilities: ["entry.publish"]
3627
+ },
3203
3628
  {
3204
- path,
3205
- surface,
3206
- expression,
3207
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${ACCESS_ROOTS.map((root) => `"${root}"`).join(", ")}`
3208
- }
3209
- ];
3210
- } catch (error) {
3211
- return [
3629
+ name: "reject",
3630
+ label: "Request changes",
3631
+ from: "in_review",
3632
+ to: "draft",
3633
+ requiredCapabilities: ["entry.publish"],
3634
+ requireComment: true
3635
+ },
3212
3636
  {
3213
- path,
3214
- surface,
3215
- expression,
3216
- message: `has invalid syntax: ${error.message}`
3637
+ name: "unpublish",
3638
+ label: "Unpublish",
3639
+ from: "published",
3640
+ to: "draft",
3641
+ requiredCapabilities: ["entry.unpublish"],
3642
+ unpublish: true
3217
3643
  }
3218
- ];
3219
- }
3644
+ ],
3645
+ roles: [
3646
+ ...editors.map((role) => ({ role, capabilities: [...EDITOR_CAPABILITIES] })),
3647
+ ...publishers.map((role) => ({ role, capabilities: [...PUBLISHER_CAPABILITIES] }))
3648
+ ]
3649
+ };
3220
3650
  }
3221
- function validateAdminConditionExpression(expression, path, allowedRoots) {
3222
- try {
3223
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3224
- if (invalidRoots.length === 0) return [];
3225
- return [
3226
- {
3227
- path,
3228
- surface: "field.admin.condition",
3229
- expression,
3230
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
3231
- }
3232
- ];
3233
- } catch (error) {
3234
- return [
3235
- {
3236
- path,
3237
- surface: "field.admin.condition",
3238
- expression,
3239
- message: `has invalid syntax: ${error.message}`
3240
- }
3241
- ];
3242
- }
3651
+ function publishingWorkflow() {
3652
+ return definePublishingWorkflow();
3243
3653
  }
3244
- function validatePreviewUrlExpression(expression, path, allowedRoots) {
3245
- try {
3246
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3247
- if (invalidRoots.length === 0) return [];
3248
- return [
3249
- {
3250
- path,
3251
- surface: "collection.admin.previewUrl",
3252
- expression,
3253
- message: `uses unsupported context ${invalidRoots.map((root) => `"${root}"`).join(", ")}. Allowed context: ${allowedRoots.map((root) => `"${root}"`).join(", ")}`
3254
- }
3255
- ];
3256
- } catch (error) {
3257
- return [
3258
- {
3259
- path,
3260
- surface: "collection.admin.previewUrl",
3261
- expression,
3262
- message: `has invalid syntax: ${error.message}`
3263
- }
3264
- ];
3265
- }
3654
+ function simplePublishingWorkflow() {
3655
+ return {
3656
+ initialState: "draft",
3657
+ draftState: "draft",
3658
+ states: [
3659
+ { name: "draft", label: "Draft", color: "neutral" },
3660
+ { name: "published", label: "Published", color: "success", published: true }
3661
+ ],
3662
+ transitions: [
3663
+ { name: "publish", label: "Publish", from: ["draft", "published"], to: "published" },
3664
+ { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
3665
+ ]
3666
+ // Intentionally no `roles`: this lightweight workflow (synthesized from
3667
+ // `drafts: true`) does no capability-based gating. Draft visibility for a
3668
+ // no-roles workflow is handled in `canViewWorkflowDraft`.
3669
+ };
3266
3670
  }
3267
- function assertValidDeclarativeHookExpression(expression, surface, path) {
3268
- const issues = validateDeclarativeHookExpression(expression, surface, path);
3269
- if (issues.length === 0) return;
3270
- throw new Error(`Invalid declarative hook at ${path}: ${issues[0].message}`);
3671
+ function publicMetadata(meta) {
3672
+ const { availableTransitions: _availableTransitions, ...safe } = meta;
3673
+ return safe;
3271
3674
  }
3272
- function assertValidDeclarativeHookResult(result, surface, path) {
3273
- if (isPlainObject(result)) return;
3274
- throw new Error(`Invalid declarative hook result at ${path}: ${surface} expressions must return an object`);
3675
+ function workflowCapabilities(workflow, user) {
3676
+ const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
3677
+ const roles = Array.isArray(user?.roles) ? user.roles : typeof user?.role === "string" ? [user.role] : [];
3678
+ for (const mapping of workflow.roles ?? []) {
3679
+ if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
3680
+ }
3681
+ return capabilities;
3275
3682
  }
3276
- function collectFieldIssues(fields, path, issues) {
3277
- fields.forEach((field, index) => {
3278
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3279
- field.hooks?.beforeChange?.forEach((hook, hookIndex) => {
3280
- if (typeof hook !== "string") return;
3281
- issues.push(
3282
- ...validateDeclarativeHookExpression(
3283
- hook,
3284
- "field.beforeChange",
3285
- `${fieldPath}.hooks.beforeChange[${hookIndex}]`
3286
- )
3287
- );
3288
- });
3289
- field.hooks?.afterRead?.forEach((hook, hookIndex) => {
3290
- if (typeof hook !== "string") return;
3291
- issues.push({
3292
- path: `${fieldPath}.hooks.afterRead[${hookIndex}]`,
3293
- surface: "unsupported",
3294
- expression: hook,
3295
- message: "declarative strings are not supported on field hooks.afterRead"
3296
- });
3297
- });
3298
- if (typeof field.admin?.hooks?.onChange === "string") {
3299
- issues.push(
3300
- ...validateDeclarativeHookExpression(
3301
- field.admin.hooks.onChange,
3302
- "field.admin.onChange",
3303
- `${fieldPath}.admin.hooks.onChange`
3304
- )
3305
- );
3306
- }
3307
- if (typeof field.admin?.hooks?.options === "string") {
3308
- issues.push({
3309
- path: `${fieldPath}.admin.hooks.options`,
3310
- surface: "unsupported",
3311
- expression: field.admin.hooks.options,
3312
- message: "declarative strings are not supported on field admin.hooks.options"
3313
- });
3314
- }
3315
- if (Array.isArray(field.fields)) {
3316
- collectFieldIssues(field.fields, fieldPath, issues);
3317
- }
3318
- if (Array.isArray(field.blocks)) {
3319
- field.blocks.forEach((block, blockIndex) => {
3320
- if (!Array.isArray(block.fields)) return;
3321
- collectFieldIssues(block.fields, `${fieldPath}.${blockSegment(block, blockIndex)}`, issues);
3322
- });
3323
- }
3683
+ function canViewWorkflowDraft(workflow, user) {
3684
+ if (!user) return false;
3685
+ const capabilities = workflowCapabilities(workflow, user);
3686
+ if (capabilities.has("entry.edit")) return true;
3687
+ if (workflow.transitions.some(
3688
+ (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
3689
+ )) {
3690
+ return true;
3691
+ }
3692
+ if (!workflow.roles || workflow.roles.length === 0) return true;
3693
+ return false;
3694
+ }
3695
+ function availableWorkflowTransitions(workflow, state, user) {
3696
+ const capabilities = workflowCapabilities(workflow, user);
3697
+ return workflow.transitions.filter((transition) => {
3698
+ const from = Array.isArray(transition.from) ? transition.from : [transition.from];
3699
+ return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
3324
3700
  });
3325
3701
  }
3326
- function collectFieldNames(fields) {
3327
- return fields.flatMap(
3328
- (field) => typeof field.name === "string" && field.name.length > 0 ? [field.name] : []
3329
- );
3702
+ function initializeWorkflowDocument(data, workflow) {
3703
+ return {
3704
+ ...data,
3705
+ __workflow: { state: workflow.initialState, revision: 1 }
3706
+ };
3707
+ }
3708
+ function publishedStateName(workflow) {
3709
+ return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
3330
3710
  }
3331
- function collectAdminConditionIssues(fields, path, rootFieldNames, issues) {
3332
- const scopeFieldNames = collectFieldNames(fields);
3333
- const allowedRoots = [.../* @__PURE__ */ new Set([...ADMIN_CONDITION_RESERVED_ROOTS, ...rootFieldNames, ...scopeFieldNames])];
3334
- fields.forEach((field, index) => {
3335
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3336
- if (typeof field.admin?.condition === "string") {
3337
- issues.push(
3338
- ...validateAdminConditionExpression(
3339
- field.admin.condition,
3340
- `${fieldPath}.admin.condition`,
3341
- allowedRoots
3342
- )
3343
- );
3344
- }
3345
- if (Array.isArray(field.fields)) {
3346
- collectAdminConditionIssues(field.fields, fieldPath, rootFieldNames, issues);
3711
+ function materializeWorkflowDocument(doc, workflow, user) {
3712
+ const meta = doc.__workflow;
3713
+ const publishedState = publishedStateName(workflow);
3714
+ if (!meta) {
3715
+ const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
3716
+ const state = publishedStateName(workflow);
3717
+ return {
3718
+ ...working2,
3719
+ _workflow: {
3720
+ state,
3721
+ revision: 1,
3722
+ availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
3723
+ }
3724
+ };
3725
+ }
3726
+ const { __published, __workflow, ...working } = doc;
3727
+ if (!canViewWorkflowDraft(workflow, user)) {
3728
+ if (!__published || typeof __published !== "object") {
3729
+ if (meta.state === publishedState) {
3730
+ return { ...working, _workflow: publicMetadata(meta) };
3731
+ }
3732
+ return null;
3347
3733
  }
3348
- if (Array.isArray(field.blocks)) {
3349
- field.blocks.forEach((block, blockIndex) => {
3350
- if (!Array.isArray(block.fields)) return;
3351
- collectAdminConditionIssues(
3352
- block.fields,
3353
- `${fieldPath}.${blockSegment(block, blockIndex)}`,
3354
- rootFieldNames,
3355
- issues
3356
- );
3357
- });
3734
+ return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
3735
+ }
3736
+ return {
3737
+ ...working,
3738
+ _workflow: {
3739
+ ...meta,
3740
+ availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
3358
3741
  }
3359
- });
3742
+ };
3360
3743
  }
3361
- function collectFieldAccessIssues(fields, path, issues) {
3362
- fields.forEach((field, index) => {
3363
- const fieldPath = `${path}.${fieldSegment(field, index)}`;
3364
- if (typeof field.access?.read === "string") {
3365
- issues.push(...validateDeclarativeAccessExpression(field.access.read, "field.access", `${fieldPath}.access.read`));
3366
- }
3367
- if (typeof field.access?.create === "string") {
3368
- issues.push(
3369
- ...validateDeclarativeAccessExpression(field.access.create, "field.access", `${fieldPath}.access.create`)
3370
- );
3371
- }
3372
- if (typeof field.access?.update === "string") {
3373
- issues.push(
3374
- ...validateDeclarativeAccessExpression(field.access.update, "field.access", `${fieldPath}.access.update`)
3375
- );
3376
- }
3377
- if (Array.isArray(field.fields)) {
3378
- collectFieldAccessIssues(field.fields, fieldPath, issues);
3379
- }
3380
- if (Array.isArray(field.blocks)) {
3381
- field.blocks.forEach((block, blockIndex) => {
3382
- if (!Array.isArray(block.fields)) return;
3383
- collectFieldAccessIssues(
3384
- block.fields,
3385
- `${fieldPath}.${blockSegment(block, blockIndex)}`,
3386
- issues
3387
- );
3388
- });
3389
- }
3390
- });
3744
+ function eventId() {
3745
+ return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3391
3746
  }
3392
- function collectHookIssues(hooks, scope, path, issues) {
3393
- if (!hooks) return;
3394
- const supported = {
3395
- beforeRead: `${scope}.beforeRead`,
3396
- afterRead: `${scope}.afterRead`,
3397
- beforeChange: `${scope}.beforeChange`
3747
+ function createLifecycleEvent(args) {
3748
+ return {
3749
+ id: eventId(),
3750
+ name: args.name,
3751
+ collection: args.collection,
3752
+ documentId: args.documentId,
3753
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
3754
+ actorId: args.actorId,
3755
+ payload: args.payload,
3756
+ attempts: 0,
3757
+ status: "pending"
3398
3758
  };
3399
- for (const [key, value] of Object.entries(hooks)) {
3400
- if (!Array.isArray(value)) continue;
3401
- value.forEach((hook, index) => {
3402
- if (typeof hook !== "string") return;
3403
- const surface = supported[key];
3404
- if (!surface) {
3405
- issues.push({
3406
- path: `${path}.${key}[${index}]`,
3407
- surface: "unsupported",
3408
- expression: hook,
3409
- message: `declarative strings are not supported on ${scope} hooks.${key}`
3410
- });
3411
- return;
3759
+ }
3760
+ async function persistEvent(db, event) {
3761
+ await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
3762
+ }
3763
+ async function dispatchLifecycleEvent(config, event) {
3764
+ const db = config.db;
3765
+ if (!db || !config.events?.handlers.length) return;
3766
+ const maxAttempts = config.events.maxAttempts ?? 8;
3767
+ const retryDelayMs = config.events.retryDelayMs ?? 1e3;
3768
+ const attempts = event.attempts + 1;
3769
+ try {
3770
+ await db.update({
3771
+ collection: LIFECYCLE_EVENTS_COLLECTION,
3772
+ id: event.id,
3773
+ data: { status: "processing", attempts }
3774
+ });
3775
+ for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
3776
+ await db.update({
3777
+ collection: LIFECYCLE_EVENTS_COLLECTION,
3778
+ id: event.id,
3779
+ data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
3780
+ });
3781
+ } catch (error) {
3782
+ const exhausted = attempts >= maxAttempts;
3783
+ await db.update({
3784
+ collection: LIFECYCLE_EVENTS_COLLECTION,
3785
+ id: event.id,
3786
+ data: {
3787
+ status: "failed",
3788
+ attempts,
3789
+ lastError: error instanceof Error ? error.message : String(error),
3790
+ nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
3412
3791
  }
3413
- issues.push(...validateDeclarativeHookExpression(hook, surface, `${path}.${key}[${index}]`));
3414
3792
  });
3415
3793
  }
3416
3794
  }
3417
- function collectDeclarativeHookValidationIssues(config) {
3418
- const issues = [];
3419
- config.blocks?.forEach((block, blockIndex) => {
3420
- if (!Array.isArray(block.fields)) return;
3421
- collectFieldIssues(
3422
- block.fields,
3423
- blockSegment(block, blockIndex),
3424
- issues
3425
- );
3795
+ async function dispatchPendingLifecycleEvents(config, limit = 50) {
3796
+ if (!config.db || !config.events?.handlers.length) return 0;
3797
+ const maxAttempts = config.events.maxAttempts ?? 8;
3798
+ const result = await config.db.find({
3799
+ collection: LIFECYCLE_EVENTS_COLLECTION,
3800
+ where: { status: { in: ["pending", "failed"] } },
3801
+ sort: "occurredAt",
3802
+ limit
3426
3803
  });
3427
- config.collections.forEach((collection, collectionIndex) => {
3428
- const collectionPath = collectionSegment(collection, collectionIndex);
3429
- collectHookIssues(
3430
- collection.hooks,
3431
- "collection",
3432
- `${collectionPath}.hooks`,
3433
- issues
3434
- );
3435
- collectFieldIssues(collection.fields, collectionPath, issues);
3804
+ const now2 = Date.now();
3805
+ const due = result.docs.filter(
3806
+ (doc) => Number(doc.attempts ?? 0) < maxAttempts && (!doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now2)
3807
+ );
3808
+ for (const event of due) await dispatchLifecycleEvent(config, event);
3809
+ return due.length;
3810
+ }
3811
+ async function saveWorkflowDraft(args) {
3812
+ const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
3813
+ const db = config.db;
3814
+ const workflow = collection.workflow;
3815
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
3816
+ const previous = originalDoc.__workflow;
3817
+ const isLegacyPublishedDoc = !previous;
3818
+ const baselineRevision = previous?.revision ?? 1;
3819
+ const event = createLifecycleEvent({
3820
+ name: "revision.created",
3821
+ collection: collection.slug,
3822
+ documentId: id,
3823
+ actorId: user?.sub,
3824
+ payload: {
3825
+ revision: baselineRevision + 1,
3826
+ previousRevision: isLegacyPublishedDoc ? baselineRevision : previous?.revision ?? null
3827
+ }
3436
3828
  });
3437
- config.globals?.forEach((global, globalIndex) => {
3438
- const globalPath = globalSegment(global, globalIndex);
3439
- collectHookIssues(
3440
- global.hooks,
3441
- "global",
3442
- `${globalPath}.hooks`,
3443
- issues
3444
- );
3445
- collectFieldIssues(global.fields, globalPath, issues);
3829
+ const doc = await db.transaction(async (tx) => {
3830
+ const nextMeta = {
3831
+ ...previous ?? {},
3832
+ state: isLegacyPublishedDoc ? workflow.draftState ?? workflow.initialState : originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
3833
+ revision: baselineRevision + 1,
3834
+ ...isLegacyPublishedDoc ? { publishedRevision: baselineRevision } : {}
3835
+ };
3836
+ const updateData = {
3837
+ ...data,
3838
+ __workflow: nextMeta
3839
+ };
3840
+ if (isLegacyPublishedDoc) {
3841
+ const { __workflow: _legacyWorkflow, __published: _legacyPublished, ...publishedSnapshot } = originalDoc;
3842
+ updateData.__published = publishedSnapshot;
3843
+ }
3844
+ const updated = await tx.update({ collection: collection.slug, id, data: updateData });
3845
+ await persistEvent(tx, event);
3846
+ await beforeCommit?.(tx, updated);
3847
+ return updated;
3848
+ });
3849
+ void dispatchLifecycleEvent(config, event);
3850
+ return { doc, event };
3851
+ }
3852
+ async function createWorkflowDocument(args) {
3853
+ const { config, collection, data, user, beforeCommit } = args;
3854
+ const db = config.db;
3855
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
3856
+ let event;
3857
+ const doc = await db.transaction(async (tx) => {
3858
+ const created = await tx.create({ collection: collection.slug, data });
3859
+ event = createLifecycleEvent({
3860
+ name: "revision.created",
3861
+ collection: collection.slug,
3862
+ documentId: created.id,
3863
+ actorId: user?.sub,
3864
+ payload: { revision: 1, previousRevision: null }
3865
+ });
3866
+ await persistEvent(tx, event);
3867
+ await beforeCommit?.(tx, created);
3868
+ return created;
3446
3869
  });
3447
- return issues;
3870
+ void dispatchLifecycleEvent(config, event);
3871
+ return { doc, event };
3448
3872
  }
3449
- function collectDeclarativeAccessValidationIssues(config) {
3450
- const issues = [];
3451
- Object.entries(config.accessPolicies ?? {}).forEach(([name, value]) => {
3452
- if (typeof value !== "string") return;
3453
- issues.push(
3454
- ...validateDeclarativeAccessExpression(value, "config.accessPolicy", `accessPolicies.${name}`)
3873
+ async function transitionWorkflow(args) {
3874
+ const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
3875
+ const db = config.db;
3876
+ const workflow = collection.workflow;
3877
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
3878
+ const original = await db.findOne({ collection: collection.slug, id });
3879
+ if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
3880
+ const meta = original.__workflow;
3881
+ if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
3882
+ if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
3883
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
3884
+ statusCode: 409
3885
+ });
3886
+ }
3887
+ const transition = workflow.transitions.find((item) => item.name === transitionName);
3888
+ const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
3889
+ if (!transition || !fromStates.includes(meta.state)) {
3890
+ throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), {
3891
+ statusCode: 409
3892
+ });
3893
+ }
3894
+ if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
3895
+ throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
3896
+ }
3897
+ if (transition.requireComment && !comment?.trim()) {
3898
+ throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
3899
+ }
3900
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
3901
+ for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
3902
+ const events = [
3903
+ createLifecycleEvent({
3904
+ name: "workflow.transitioned",
3905
+ collection: collection.slug,
3906
+ documentId: id,
3907
+ actorId: user?.sub,
3908
+ payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
3909
+ })
3910
+ ];
3911
+ const targetState = workflow.states.find((state) => state.name === transition.to);
3912
+ if (targetState.published) {
3913
+ events.push(
3914
+ createLifecycleEvent({
3915
+ name: "entry.published",
3916
+ collection: collection.slug,
3917
+ documentId: id,
3918
+ actorId: user?.sub,
3919
+ payload: { revision: meta.revision }
3920
+ })
3455
3921
  );
3456
- });
3457
- config.blocks?.forEach((block, blockIndex) => {
3458
- if (!Array.isArray(block.fields)) return;
3459
- collectFieldAccessIssues(
3460
- block.fields,
3461
- blockSegment(block, blockIndex),
3462
- issues
3922
+ } else if (transition.unpublish) {
3923
+ events.push(
3924
+ createLifecycleEvent({
3925
+ name: "entry.unpublished",
3926
+ collection: collection.slug,
3927
+ documentId: id,
3928
+ actorId: user?.sub,
3929
+ payload: { revision: meta.publishedRevision }
3930
+ })
3463
3931
  );
3464
- });
3465
- config.collections.forEach((collection, collectionIndex) => {
3466
- const collectionPath = collectionSegment(collection, collectionIndex);
3467
- if (typeof collection.access?.read === "string") {
3468
- issues.push(
3469
- ...validateDeclarativeAccessExpression(
3470
- collection.access.read,
3471
- "collection.access",
3472
- `${collectionPath}.access.read`
3473
- )
3474
- );
3475
- }
3476
- if (typeof collection.access?.create === "string") {
3477
- issues.push(
3478
- ...validateDeclarativeAccessExpression(
3479
- collection.access.create,
3480
- "collection.access",
3481
- `${collectionPath}.access.create`
3482
- )
3483
- );
3484
- }
3485
- if (typeof collection.access?.update === "string") {
3486
- issues.push(
3487
- ...validateDeclarativeAccessExpression(
3488
- collection.access.update,
3489
- "collection.access",
3490
- `${collectionPath}.access.update`
3491
- )
3492
- );
3493
- }
3494
- if (typeof collection.access?.delete === "string") {
3495
- issues.push(
3496
- ...validateDeclarativeAccessExpression(
3497
- collection.access.delete,
3498
- "collection.access",
3499
- `${collectionPath}.access.delete`
3500
- )
3501
- );
3502
- }
3503
- if (typeof collection.access?.readAudit === "string") {
3504
- issues.push(
3505
- ...validateDeclarativeAccessExpression(
3506
- collection.access.readAudit,
3507
- "collection.access",
3508
- `${collectionPath}.access.readAudit`
3509
- )
3510
- );
3932
+ }
3933
+ const updated = await db.transaction(async (tx) => {
3934
+ const locked = await tx.findOne({ collection: collection.slug, id });
3935
+ if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
3936
+ const lockedMeta = locked.__workflow;
3937
+ if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
3938
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), {
3939
+ statusCode: 409
3940
+ });
3511
3941
  }
3512
- collectFieldAccessIssues(collection.fields, collectionPath, issues);
3513
- });
3514
- config.globals?.forEach((global, globalIndex) => {
3515
- const globalPath = globalSegment(global, globalIndex);
3516
- if (typeof global.access?.read === "string") {
3517
- issues.push(
3518
- ...validateDeclarativeAccessExpression(global.access.read, "global.access", `${globalPath}.access.read`)
3519
- );
3942
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
3943
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
3944
+ const nextMeta = {
3945
+ ...lockedMeta,
3946
+ state: transition.to,
3947
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now2, publishedBy: user?.sub } : {},
3948
+ ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
3949
+ };
3950
+ const data = { __workflow: nextMeta };
3951
+ if (targetState.published) data.__published = working;
3952
+ if (transition.unpublish) data.__published = null;
3953
+ const next = await tx.update({ collection: collection.slug, id, data });
3954
+ if (transition.onTransition) {
3955
+ await transition.onTransition({
3956
+ transition,
3957
+ from: lockedMeta.state,
3958
+ to: transition.to,
3959
+ doc: locked,
3960
+ input,
3961
+ comment,
3962
+ user,
3963
+ req,
3964
+ tx
3965
+ });
3520
3966
  }
3521
- if (typeof global.access?.update === "string") {
3522
- issues.push(
3523
- ...validateDeclarativeAccessExpression(
3524
- global.access.update,
3525
- "global.access",
3526
- `${globalPath}.access.update`
3527
- )
3528
- );
3967
+ await tx.create({
3968
+ collection: WORKFLOW_HISTORY_COLLECTION,
3969
+ data: {
3970
+ collection: collection.slug,
3971
+ documentId: id,
3972
+ transition: transition.name,
3973
+ from: lockedMeta.state,
3974
+ to: transition.to,
3975
+ revision: lockedMeta.revision,
3976
+ comment: comment ?? null,
3977
+ actorId: user?.sub ?? null,
3978
+ createdAt: now2
3979
+ }
3980
+ });
3981
+ for (const event of events) await persistEvent(tx, event);
3982
+ if (collection.audit) {
3983
+ await tx.create({
3984
+ collection: "__audit",
3985
+ data: {
3986
+ collection: collection.slug,
3987
+ documentId: id,
3988
+ operation: "workflow.transition",
3989
+ user: user?.sub ?? null,
3990
+ timestamp: now2,
3991
+ changes: JSON.stringify({
3992
+ transition: transition.name,
3993
+ from: lockedMeta.state,
3994
+ to: transition.to,
3995
+ revision: lockedMeta.revision
3996
+ })
3997
+ }
3998
+ });
3529
3999
  }
3530
- collectFieldAccessIssues(global.fields, globalPath, issues);
3531
- });
3532
- return issues;
3533
- }
3534
- function collectAdminConditionValidationIssues(config) {
3535
- const issues = [];
3536
- config.blocks?.forEach((block, blockIndex) => {
3537
- if (!Array.isArray(block.fields)) return;
3538
- collectAdminConditionIssues(
3539
- block.fields,
3540
- blockSegment(block, blockIndex),
3541
- collectFieldNames(block.fields),
3542
- issues
3543
- );
3544
- });
3545
- config.collections.forEach((collection, collectionIndex) => {
3546
- const collectionPath = collectionSegment(collection, collectionIndex);
3547
- const rootFieldNames = collectFieldNames(collection.fields);
3548
- collectAdminConditionIssues(collection.fields, collectionPath, rootFieldNames, issues);
3549
- });
3550
- config.globals?.forEach((global, globalIndex) => {
3551
- const globalPath = globalSegment(global, globalIndex);
3552
- const rootFieldNames = collectFieldNames(global.fields);
3553
- collectAdminConditionIssues(global.fields, globalPath, rootFieldNames, issues);
4000
+ return next;
3554
4001
  });
3555
- return issues;
4002
+ for (const event of events) void dispatchLifecycleEvent(config, event);
4003
+ for (const hook of workflow.hooks?.afterTransition ?? []) {
4004
+ try {
4005
+ await hook({ ...hookContext, doc: updated, event: events[0] });
4006
+ } catch (error) {
4007
+ try {
4008
+ const obs = config?.__observability;
4009
+ obs?.recordWorkflowHookFailure?.({
4010
+ collection: collection.slug,
4011
+ transition: transition.name
4012
+ });
4013
+ const logger = config?.logger;
4014
+ if (logger && typeof logger.child === "function") {
4015
+ logger.child({ component: "workflow" }).error({
4016
+ err: error,
4017
+ msg: "afterTransition hook failed",
4018
+ collection: collection.slug,
4019
+ transition: transition.name,
4020
+ documentId: id
4021
+ });
4022
+ } else if (logger && typeof logger.error === "function") {
4023
+ logger.error({
4024
+ err: error,
4025
+ msg: "afterTransition hook failed",
4026
+ collection: collection.slug,
4027
+ transition: transition.name,
4028
+ documentId: id
4029
+ });
4030
+ } else {
4031
+ console.error("afterTransition hook failed:", error);
4032
+ }
4033
+ } catch {
4034
+ console.error("afterTransition hook failed:", error);
4035
+ }
4036
+ }
4037
+ }
4038
+ return updated;
3556
4039
  }
3557
- function collectPreviewUrlValidationIssues(config) {
3558
- const issues = [];
3559
- config.collections.forEach((collection, collectionIndex) => {
3560
- if (typeof collection.admin?.previewUrl !== "string") return;
3561
- const collectionPath = collectionSegment(collection, collectionIndex);
3562
- const allowedRoots = [
3563
- .../* @__PURE__ */ new Set([...PREVIEW_URL_RESERVED_ROOTS, ...collectFieldNames(collection.fields)])
3564
- ];
3565
- issues.push(
3566
- ...validatePreviewUrlExpression(
3567
- collection.admin.previewUrl,
3568
- `${collectionPath}.admin.previewUrl`,
3569
- allowedRoots
3570
- )
3571
- );
3572
- });
3573
- return issues;
4040
+
4041
+ // src/utils/admin-auth.ts
4042
+ function getAdminAuthCollection(config) {
4043
+ const adminCollection = resolveAdminAuthCollection(config.collections, config.adminAuth?.collectionSlug);
4044
+ return adminCollection ?? null;
3574
4045
  }
3575
- function assertValidAdminConditionsInConfig(config, source = "config") {
3576
- const diagnostics = collectAdminConditionValidationIssues(config).map((issue) => ({
3577
- severity: "error",
3578
- source: "adminCondition",
3579
- path: issue.path,
3580
- surface: issue.surface,
3581
- message: issue.message,
3582
- expression: issue.expression
3583
- }));
3584
- if (diagnostics.length === 0) return;
3585
- throw new ConfigValidationError(source, diagnostics);
4046
+ function resolveAdminAuthCollection(collections, collectionSlug) {
4047
+ const adminsCollection = collections.find((collection) => collection.slug === "__admins");
4048
+ if (adminsCollection) return adminsCollection;
4049
+ if (collectionSlug) {
4050
+ const requestedCollection = collections.find((collection) => collection.slug === collectionSlug);
4051
+ if (requestedCollection) return requestedCollection;
4052
+ }
4053
+ return collections.find((collection) => collection.auth);
3586
4054
  }
3587
- function assertValidPreviewUrlsInConfig(config, source = "config") {
3588
- const diagnostics = collectPreviewUrlValidationIssues(config).map((issue) => ({
3589
- severity: "error",
3590
- source: "previewUrl",
3591
- path: issue.path,
3592
- surface: issue.surface,
3593
- message: issue.message,
3594
- expression: issue.expression
4055
+ function getPublicAdminAuthConfig(adminAuth, collections) {
4056
+ const providers = (adminAuth?.providers ?? []).map((provider) => ({
4057
+ id: provider.id,
4058
+ type: provider.type,
4059
+ displayName: provider.displayName || humanizeProviderName(provider.id, provider.type),
4060
+ autoRedirect: provider.autoRedirect
3595
4061
  }));
3596
- if (diagnostics.length === 0) return;
3597
- throw new ConfigValidationError(source, diagnostics);
4062
+ const resolvedCollectionSlug = collections ? resolveAdminAuthCollection(collections, adminAuth?.collectionSlug)?.slug : adminAuth?.collectionSlug;
4063
+ return {
4064
+ mode: adminAuth?.mode ?? "local",
4065
+ collectionSlug: resolvedCollectionSlug,
4066
+ provisioningMode: adminAuth?.provisioningMode,
4067
+ providers
4068
+ };
3598
4069
  }
3599
- function collectConfigDiagnostics(config) {
3600
- const hookIssues = collectDeclarativeHookValidationIssues(config).map((issue) => ({
3601
- severity: "error",
3602
- source: "declarativeHook",
3603
- path: issue.path,
3604
- surface: issue.surface,
3605
- message: issue.message,
3606
- expression: issue.expression
3607
- }));
3608
- const accessIssues = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
3609
- severity: "error",
3610
- source: "declarativeAccess",
3611
- path: issue.path,
3612
- surface: issue.surface,
3613
- message: issue.message,
3614
- expression: issue.expression
3615
- }));
3616
- const adminConditionIssues = collectAdminConditionValidationIssues(config).map((issue) => ({
3617
- severity: "error",
3618
- source: "adminCondition",
3619
- path: issue.path,
3620
- surface: issue.surface,
3621
- message: issue.message,
3622
- expression: issue.expression
3623
- }));
3624
- const previewUrlIssues = collectPreviewUrlValidationIssues(config).map((issue) => ({
3625
- severity: "error",
3626
- source: "previewUrl",
3627
- path: issue.path,
3628
- surface: issue.surface,
3629
- message: issue.message,
3630
- expression: issue.expression
3631
- }));
3632
- return [...hookIssues, ...accessIssues, ...adminConditionIssues, ...previewUrlIssues];
4070
+ function humanizeProviderName(id, type) {
4071
+ const cleaned = id.replace(/[-_]+/g, " ").trim();
4072
+ if (!cleaned) return type.toUpperCase();
4073
+ return cleaned.replace(/\b\w/g, (char) => char.toUpperCase());
4074
+ }
4075
+ function getAdminRoleForCollection(collection) {
4076
+ if (!collection?.auth) return "admin";
4077
+ if (typeof collection.auth === "object" && collection.auth.adminRole) {
4078
+ return collection.auth.adminRole;
4079
+ }
4080
+ return "admin";
4081
+ }
4082
+ function isUserAdmin(user, collection) {
4083
+ if (!user) return false;
4084
+ const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
4085
+ if (roles.length === 0) return false;
4086
+ const configuredRole = getAdminRoleForCollection(collection);
4087
+ if (roles.includes(configuredRole)) return true;
4088
+ return roles.includes("admin") || roles.includes("super_admin");
3633
4089
  }
3634
- function formatConfigDiagnostics(source, diagnostics, options = {}) {
3635
- const color = options.color ?? false;
3636
- const header = `${colorize("Config validation failed", 31, color)} in ${colorize(source, 36, color)}`;
3637
- 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)}`;
3638
- const lines = diagnostics.flatMap((issue, index) => [
3639
- `${colorize(`${index + 1}.`, 31, color)} ${colorize(issue.path, 1, color)}`,
3640
- ` ${issue.message}`,
3641
- ` ${colorize("surface:", 90, color)} ${issue.surface}`
3642
- ]);
3643
- return `${header}
3644
- ${lines.join("\n")}
3645
4090
 
3646
- ${docLink}`;
4091
+ // src/utils/block-references.ts
4092
+ function mergeUniqueBlocks(blocks) {
4093
+ const seen = /* @__PURE__ */ new Map();
4094
+ for (const block of blocks) {
4095
+ const existing = seen.get(block.slug);
4096
+ if (existing && existing !== block) {
4097
+ throw new Error(
4098
+ `Duplicate block slug "${block.slug}" found in the reusable block registry. Block slugs must be unique.`
4099
+ );
4100
+ }
4101
+ if (!existing) seen.set(block.slug, block);
4102
+ }
4103
+ return Array.from(seen.values());
3647
4104
  }
3648
- var ConfigValidationError = class extends Error {
3649
- diagnostics;
3650
- source;
3651
- constructor(source, diagnostics) {
3652
- super(formatConfigDiagnostics(source, diagnostics));
3653
- this.name = "ConfigValidationError";
3654
- this.source = source;
3655
- this.diagnostics = diagnostics;
4105
+ function buildRegistry(blocks) {
4106
+ return new Map(blocks.map((block) => [block.slug, block]));
4107
+ }
4108
+ function resolveField(field, registry, blockCache) {
4109
+ const next = { ...field };
4110
+ if (next.fields) {
4111
+ next.fields = next.fields.map(
4112
+ (child) => resolveField(child, registry, blockCache)
4113
+ );
3656
4114
  }
3657
- };
3658
- function isConfigValidationError(error) {
3659
- return error instanceof ConfigValidationError;
4115
+ if (next.type !== "blocks") return next;
4116
+ const hasInlineBlocks = Array.isArray(next.blocks) && next.blocks.length > 0;
4117
+ const hasReferences = Array.isArray(next.blockReferences) && next.blockReferences.length > 0;
4118
+ if (hasInlineBlocks && hasReferences) {
4119
+ throw new Error(
4120
+ `Blocks field "${next.name ?? "(unnamed)"}" cannot define both "blocks" and "blockReferences". Use one or the other.`
4121
+ );
4122
+ }
4123
+ if (hasReferences) {
4124
+ next.blocks = next.blockReferences.map((slug) => {
4125
+ const block = registry.get(slug);
4126
+ if (!block) {
4127
+ throw new Error(
4128
+ `Unknown block reference "${slug}" on blocks field "${next.name ?? "(unnamed)"}". Add it to defineConfig({ blocks: [...] }).`
4129
+ );
4130
+ }
4131
+ return resolveBlock(block, registry, blockCache);
4132
+ });
4133
+ return next;
4134
+ }
4135
+ if (hasInlineBlocks) {
4136
+ next.blocks = next.blocks.map(
4137
+ (block) => resolveBlock(block, registry, blockCache)
4138
+ );
4139
+ }
4140
+ return next;
3660
4141
  }
3661
- function assertValidDeclarativeHooksInConfig(config, source = "config") {
3662
- const diagnostics = collectDeclarativeHookValidationIssues(config).map((issue) => ({
3663
- severity: "error",
3664
- source: "declarativeHook",
3665
- path: issue.path,
3666
- surface: issue.surface,
3667
- message: issue.message,
3668
- expression: issue.expression
3669
- }));
3670
- if (diagnostics.length === 0) return;
3671
- throw new ConfigValidationError(source, diagnostics);
4142
+ function resolveBlock(block, registry, blockCache) {
4143
+ const cached = blockCache.get(block);
4144
+ if (cached) return cached;
4145
+ const resolved = {
4146
+ ...block,
4147
+ fields: []
4148
+ };
4149
+ blockCache.set(block, resolved);
4150
+ resolved.fields = block.fields.map(
4151
+ (field) => resolveField(field, registry, blockCache)
4152
+ );
4153
+ return resolved;
3672
4154
  }
3673
- function assertValidDeclarativeAccessInConfig(config, source = "config") {
3674
- const diagnostics = collectDeclarativeAccessValidationIssues(config).map((issue) => ({
3675
- severity: "error",
3676
- source: "declarativeAccess",
3677
- path: issue.path,
3678
- surface: issue.surface,
3679
- message: issue.message,
3680
- expression: issue.expression
4155
+ function resolveFields(fields, registry, blockCache) {
4156
+ return fields.map((field) => resolveField(field, registry, blockCache));
4157
+ }
4158
+ function normalizeSchemaFragment(fragment) {
4159
+ const reusableBlocks = mergeUniqueBlocks(fragment.blocks ?? []);
4160
+ const registry = buildRegistry(reusableBlocks);
4161
+ const blockCache = /* @__PURE__ */ new WeakMap();
4162
+ const resolvedBlocks = reusableBlocks.map(
4163
+ (block) => resolveBlock(block, registry, blockCache)
4164
+ );
4165
+ const resolvedCollections = (fragment.collections ?? []).map(
4166
+ (collection) => ({
4167
+ ...collection,
4168
+ fields: resolveFields(collection.fields ?? [], registry, blockCache)
4169
+ })
4170
+ );
4171
+ const resolvedGlobals = (fragment.globals ?? []).map((global) => ({
4172
+ ...global,
4173
+ fields: resolveFields(global.fields ?? [], registry, blockCache)
3681
4174
  }));
3682
- if (diagnostics.length === 0) return;
3683
- throw new ConfigValidationError(source, diagnostics);
4175
+ return {
4176
+ ...fragment,
4177
+ ...fragment.blocks ? { blocks: resolvedBlocks } : {},
4178
+ ...fragment.collections ? { collections: resolvedCollections } : {},
4179
+ ...fragment.globals ? { globals: resolvedGlobals } : {}
4180
+ };
3684
4181
  }
3685
4182
 
3686
4183
  // src/utils/id.ts
@@ -3864,7 +4361,7 @@ var AUDIT_COLLECTION = {
3864
4361
  name: "operation",
3865
4362
  type: "select",
3866
4363
  label: "Operation",
3867
- options: ["create", "update", "delete"],
4364
+ options: ["create", "update", "delete", "trash", "restore", "purge", "trash-empty"],
3868
4365
  required: true
3869
4366
  },
3870
4367
  { name: "user", type: "text", label: "User ID" },
@@ -3958,12 +4455,14 @@ function normalizeConfig(config) {
3958
4455
  assertValidDeclarativeAccessInConfig(schemaAwareConfig, "config");
3959
4456
  assertValidAdminConditionsInConfig(schemaAwareConfig, "config");
3960
4457
  assertValidPreviewUrlsInConfig(schemaAwareConfig, "config");
4458
+ assertValidTrashInConfig(schemaAwareConfig, "config");
3961
4459
  const collections = schemaAwareConfig?.collections || [];
3962
4460
  const globals = schemaAwareConfig?.globals || [];
3963
4461
  const needsAudit = collections.some((col) => col.audit);
3964
4462
  const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
3965
4463
  const needsTasks = (config.tasks?.length ?? 0) > 0;
3966
4464
  const needsAuthSessions = collections.some((col) => !!col.auth);
4465
+ const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
3967
4466
  const adminAuthCollectionSlug = getAdminAuthCollection({
3968
4467
  collections,
3969
4468
  adminAuth: schemaAwareConfig.adminAuth
@@ -4201,6 +4700,9 @@ function normalizeConfig(config) {
4201
4700
  if (needsAuthSessions && !normalizedCollections.some((col) => col.slug === AUTH_SESSIONS_COLLECTION)) {
4202
4701
  systemCollections.push(AUTH_SESSIONS_COLLECTION_CONFIG);
4203
4702
  }
4703
+ if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
4704
+ systemCollections.push(TRASH_COLLECTION_CONFIG);
4705
+ }
4204
4706
  return {
4205
4707
  ...schemaAwareConfig,
4206
4708
  collections: [...normalizedCollections, ...systemCollections],
@@ -4563,206 +5065,6 @@ function buildSqlOrderBy(sort, existingColumns, numericFields, dialect) {
4563
5065
  }).join(", ");
4564
5066
  }
4565
5067
 
4566
- // src/utils/hooks.ts
4567
- var import_jexl3 = __toESM(require_Jexl(), 1);
4568
- async function runCollectionHooks(hooks, args, options = {}) {
4569
- if (!hooks || hooks.length === 0) {
4570
- return args.data ?? args.doc ?? args.query ?? void 0;
4571
- }
4572
- const payloadKey = args.data !== void 0 ? "data" : args.doc !== void 0 ? "doc" : "query" in args ? "query" : void 0;
4573
- let currentPayload = payloadKey === void 0 ? void 0 : args[payloadKey];
4574
- for (const hook of hooks) {
4575
- try {
4576
- const hookArgs = {
4577
- ...args,
4578
- ...payloadKey !== void 0 ? { [payloadKey]: currentPayload } : {}
4579
- };
4580
- const result = typeof hook === "string" ? await (() => {
4581
- if (options.surface) {
4582
- assertValidDeclarativeHookExpression(
4583
- hook,
4584
- options.surface,
4585
- options.path ?? options.surface
4586
- );
4587
- }
4588
- return import_jexl3.default.eval(hook, hookArgs);
4589
- })() : await hook(hookArgs);
4590
- if (result !== void 0) {
4591
- if (typeof hook === "string" && options.surface) {
4592
- assertValidDeclarativeHookResult(
4593
- result,
4594
- options.surface,
4595
- options.path ?? options.surface
4596
- );
4597
- }
4598
- currentPayload = typeof hook === "string" && currentPayload && typeof currentPayload === "object" && !Array.isArray(currentPayload) && result && typeof result === "object" && !Array.isArray(result) ? {
4599
- ...currentPayload,
4600
- ...result
4601
- } : result;
4602
- }
4603
- } catch (err) {
4604
- if (options.isolated) {
4605
- console.error("Side-effect hook failed after a successful write", err);
4606
- } else {
4607
- throw err;
4608
- }
4609
- }
4610
- }
4611
- return currentPayload;
4612
- }
4613
- async function executeFieldBeforeChange(fields, data, originalDoc, user, db, pathPrefix = "") {
4614
- if (!data || typeof data !== "object") return data;
4615
- const result = { ...data };
4616
- for (const field of fields) {
4617
- if (!field.name) continue;
4618
- const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
4619
- const value = result[field.name];
4620
- const origValue = originalDoc?.[field.name];
4621
- let updatedValue = value;
4622
- if (field.hooks?.beforeChange) {
4623
- for (const hook of field.hooks.beforeChange) {
4624
- const hookArgs = {
4625
- value: updatedValue,
4626
- originalDoc: originalDoc ?? void 0,
4627
- data: result,
4628
- user,
4629
- db
4630
- };
4631
- updatedValue = typeof hook === "string" ? await (() => {
4632
- assertValidDeclarativeHookExpression(
4633
- hook,
4634
- "field.beforeChange",
4635
- fieldPath
4636
- );
4637
- return import_jexl3.default.eval(hook, hookArgs);
4638
- })() : await hook(hookArgs);
4639
- }
4640
- result[field.name] = updatedValue;
4641
- }
4642
- if (updatedValue !== void 0 && updatedValue !== null) {
4643
- if (field.type === "object" && field.fields) {
4644
- result[field.name] = await executeFieldBeforeChange(
4645
- field.fields,
4646
- updatedValue,
4647
- origValue,
4648
- user,
4649
- db,
4650
- fieldPath
4651
- );
4652
- } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
4653
- const arrayResult = [];
4654
- for (let i = 0; i < updatedValue.length; i++) {
4655
- const item = updatedValue[i];
4656
- const origItem = Array.isArray(origValue) ? origValue[i] : null;
4657
- arrayResult.push(
4658
- await executeFieldBeforeChange(
4659
- field.fields,
4660
- item,
4661
- origItem,
4662
- user,
4663
- db,
4664
- fieldPath
4665
- )
4666
- );
4667
- }
4668
- result[field.name] = arrayResult;
4669
- } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
4670
- const blocksResult = [];
4671
- for (let i = 0; i < updatedValue.length; i++) {
4672
- const blockData = updatedValue[i];
4673
- const origBlock = Array.isArray(origValue) ? origValue[i] : null;
4674
- const blockConfig = field.blocks.find(
4675
- (b) => b.slug === blockData.blockType
4676
- );
4677
- if (blockConfig) {
4678
- blocksResult.push(
4679
- await executeFieldBeforeChange(
4680
- blockConfig.fields,
4681
- blockData,
4682
- origBlock,
4683
- user,
4684
- db,
4685
- fieldPath
4686
- )
4687
- );
4688
- } else {
4689
- blocksResult.push(blockData);
4690
- }
4691
- }
4692
- result[field.name] = blocksResult;
4693
- }
4694
- }
4695
- }
4696
- return result;
4697
- }
4698
- async function executeFieldAfterRead(fields, doc, user, db) {
4699
- if (!doc || typeof doc !== "object") return doc;
4700
- const result = { ...doc };
4701
- for (const field of fields) {
4702
- if (!field.name) continue;
4703
- const value = result[field.name];
4704
- let updatedValue = value;
4705
- if (field.hooks?.afterRead) {
4706
- for (const hook of field.hooks.afterRead) {
4707
- const typedHook = hook;
4708
- const hookArgs = {
4709
- value: updatedValue,
4710
- doc: result,
4711
- user,
4712
- db
4713
- };
4714
- updatedValue = await typedHook(hookArgs);
4715
- }
4716
- result[field.name] = updatedValue;
4717
- }
4718
- if (updatedValue !== void 0 && updatedValue !== null) {
4719
- if (field.type === "object" && field.fields) {
4720
- result[field.name] = await executeFieldAfterRead(
4721
- field.fields,
4722
- updatedValue,
4723
- user,
4724
- db
4725
- );
4726
- } else if (field.type === "array" && field.fields && Array.isArray(updatedValue)) {
4727
- const arrayResult = [];
4728
- for (const item of updatedValue) {
4729
- arrayResult.push(
4730
- await executeFieldAfterRead(
4731
- field.fields,
4732
- item,
4733
- user,
4734
- db
4735
- )
4736
- );
4737
- }
4738
- result[field.name] = arrayResult;
4739
- } else if (field.type === "blocks" && field.blocks && Array.isArray(updatedValue)) {
4740
- const blocksResult = [];
4741
- for (const blockData of updatedValue) {
4742
- const typedBlock = blockData;
4743
- const blockConfig = field.blocks.find(
4744
- (b) => b.slug === typedBlock.blockType
4745
- );
4746
- if (blockConfig) {
4747
- blocksResult.push(
4748
- await executeFieldAfterRead(
4749
- blockConfig.fields,
4750
- typedBlock,
4751
- user,
4752
- db
4753
- )
4754
- );
4755
- } else {
4756
- blocksResult.push(blockData);
4757
- }
4758
- }
4759
- result[field.name] = blocksResult;
4760
- }
4761
- }
4762
- }
4763
- return result;
4764
- }
4765
-
4766
5068
  // src/utils/openapi.ts
4767
5069
  function getCollectionLabels(collection) {
4768
5070
  return collection.labels || { singular: collection.slug, plural: collection.slug };
@@ -7186,10 +7488,12 @@ var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
7186
7488
  "settings",
7187
7489
  "admin",
7188
7490
  "preferences",
7189
- "auth"
7491
+ "auth",
7492
+ "trash"
7190
7493
  ]);
7191
7494
  function assertValidNavigationSlugs(navigation) {
7192
7495
  for (const item of navigation) {
7496
+ if (item.dashboard || item.trash) continue;
7193
7497
  if (item.slug) {
7194
7498
  const normalizedSlug = item.slug.toLowerCase().trim().replace(/^\//, "");
7195
7499
  if (RESERVED_NAVIGATION_SLUGS.has(normalizedSlug)) {
@@ -7269,6 +7573,21 @@ function compileNavigation(config) {
7269
7573
  });
7270
7574
  continue;
7271
7575
  }
7576
+ if (item.trash) {
7577
+ registerItem(groupName, {
7578
+ id: "trash",
7579
+ type: "trash",
7580
+ slug: "trash",
7581
+ label: item.label || "Trash",
7582
+ icon: item.icon || "Trash2",
7583
+ group: groupName,
7584
+ order: item.order ?? 950,
7585
+ views: [],
7586
+ badge: item.badge,
7587
+ access: item.access
7588
+ });
7589
+ continue;
7590
+ }
7272
7591
  if (item.collection) {
7273
7592
  const col = collectionsBySlug.get(item.collection);
7274
7593
  const combinedViews = [
@@ -7378,6 +7697,23 @@ function compileNavigation(config) {
7378
7697
  views: []
7379
7698
  });
7380
7699
  }
7700
+ const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7701
+ if (!mentionedTrash) {
7702
+ const hasTrash = collections.some(
7703
+ (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
7704
+ );
7705
+ if (hasTrash) {
7706
+ registerItem(void 0, {
7707
+ id: "trash",
7708
+ type: "trash",
7709
+ slug: "trash",
7710
+ label: "Trash",
7711
+ icon: "Trash2",
7712
+ order: 950,
7713
+ views: []
7714
+ });
7715
+ }
7716
+ }
7381
7717
  const sortAndSpliceItems = (items) => {
7382
7718
  const sorted = [...items].sort((a, b) => a.order - b.order);
7383
7719
  const explicitMap = /* @__PURE__ */ new Map();
@@ -7545,6 +7881,133 @@ function defineImageField(config) {
7545
7881
  function defineJoinField(config) {
7546
7882
  return _defineJoinFieldBase(config);
7547
7883
  }
7884
+ function defineCreatedAtField(config) {
7885
+ const type = config?.type ?? "datetime";
7886
+ const name = config?.name ?? "createdAt";
7887
+ return {
7888
+ name,
7889
+ label: "Created At",
7890
+ ...config,
7891
+ type,
7892
+ admin: {
7893
+ readOnly: true,
7894
+ ...config?.admin
7895
+ }
7896
+ };
7897
+ }
7898
+ function defineUpdatedAtField(config) {
7899
+ const type = config?.type ?? "datetime";
7900
+ const name = config?.name ?? "updatedAt";
7901
+ return {
7902
+ name,
7903
+ label: "Updated At",
7904
+ ...config,
7905
+ type,
7906
+ admin: {
7907
+ readOnly: true,
7908
+ ...config?.admin
7909
+ }
7910
+ };
7911
+ }
7912
+ function defineCreatedByField(config) {
7913
+ const name = config?.name ?? "createdBy";
7914
+ if (config?.type === "text") {
7915
+ const { type: _2, ...rest2 } = config;
7916
+ return {
7917
+ name,
7918
+ label: "Created By",
7919
+ ...rest2,
7920
+ type: "text",
7921
+ admin: {
7922
+ readOnly: true,
7923
+ ...rest2?.admin
7924
+ }
7925
+ };
7926
+ }
7927
+ const { type: _, relationTo = "users", ...rest } = config || {};
7928
+ return {
7929
+ name,
7930
+ label: "Created By",
7931
+ ...rest,
7932
+ type: "relationship",
7933
+ relationTo,
7934
+ admin: {
7935
+ readOnly: true,
7936
+ ...rest?.admin
7937
+ },
7938
+ access: {
7939
+ update: () => false,
7940
+ ...rest?.access
7941
+ }
7942
+ };
7943
+ }
7944
+ function defineUpdatedByField(config) {
7945
+ const name = config?.name ?? "updatedBy";
7946
+ if (config?.type === "text") {
7947
+ const { type: _2, ...rest2 } = config;
7948
+ return {
7949
+ name,
7950
+ label: "Updated By",
7951
+ ...rest2,
7952
+ type: "text",
7953
+ admin: {
7954
+ readOnly: true,
7955
+ ...rest2?.admin
7956
+ }
7957
+ };
7958
+ }
7959
+ const { type: _, relationTo = "users", ...rest } = config || {};
7960
+ return {
7961
+ name,
7962
+ label: "Updated By",
7963
+ ...rest,
7964
+ type: "relationship",
7965
+ relationTo,
7966
+ admin: {
7967
+ readOnly: true,
7968
+ ...rest?.admin
7969
+ },
7970
+ access: {
7971
+ update: () => false,
7972
+ ...rest?.access
7973
+ }
7974
+ };
7975
+ }
7976
+ var DEFAULT_ROLES = [
7977
+ { value: "admin", label: "Admin" },
7978
+ { value: "editor", label: "Editor" },
7979
+ { value: "viewer", label: "Viewer" }
7980
+ ];
7981
+ function defineRolesField(config) {
7982
+ const isMultiple = Boolean(config?.multiple || config?.hasMany || config?.type === "multiSelect");
7983
+ const type = isMultiple ? "multiSelect" : "select";
7984
+ const name = config?.name ?? "roles";
7985
+ const label = config?.label ?? (name === "role" ? "Role" : "Roles");
7986
+ const rawRoles = config?.roles ?? config?.options ?? DEFAULT_ROLES;
7987
+ const resolvedOptions = Array.isArray(rawRoles) ? rawRoles.map(
7988
+ (r) => typeof r === "string" ? { value: r, label: r.charAt(0).toUpperCase() + r.slice(1).replace(/[-_]/g, " ") } : r
7989
+ ) : rawRoles;
7990
+ const lastOption = Array.isArray(resolvedOptions) && resolvedOptions.length > 0 ? resolvedOptions[resolvedOptions.length - 1] : void 0;
7991
+ const defaultRoleValue = lastOption ? typeof lastOption === "string" ? lastOption : lastOption.value : "viewer";
7992
+ const defaultValue2 = config?.defaultValue !== void 0 ? config.defaultValue : isMultiple ? [defaultRoleValue] : defaultRoleValue;
7993
+ const adminRoles = Array.isArray(config?.adminRole) ? config.adminRole : typeof config?.adminRole === "string" ? [config.adminRole] : ["admin"];
7994
+ const defaultUpdateAccess = adminRoles.map((r) => `user.role == '${r}' || (user.roles != null && '${r}' in user.roles)`).join(" || ");
7995
+ const { multiple: _, roles: __, adminRole: ___, ...rest } = config || {};
7996
+ return {
7997
+ name,
7998
+ label,
7999
+ defaultValue: defaultValue2,
8000
+ options: resolvedOptions,
8001
+ access: {
8002
+ update: defaultUpdateAccess,
8003
+ ...rest.access
8004
+ },
8005
+ ...rest,
8006
+ type,
8007
+ ...isMultiple ? { hasMany: true } : {}
8008
+ };
8009
+ }
8010
+ var defineRoles = defineRolesField;
7548
8011
  function defineTab(args) {
7549
8012
  return args.fields.map((field) => ({
7550
8013
  ...field,
@@ -7579,6 +8042,8 @@ export {
7579
8042
  RESERVED_NAVIGATION_SLUGS,
7580
8043
  TASK_LOCKS_COLLECTION,
7581
8044
  TASK_LOCKS_COLLECTION_CONFIG,
8045
+ TRASH_COLLECTION,
8046
+ TRASH_COLLECTION_CONFIG,
7582
8047
  UNASSIGNED_GROUP_KEY,
7583
8048
  ValidationError,
7584
8049
  WORKFLOW_HISTORY_COLLECTION,
@@ -7592,6 +8057,7 @@ export {
7592
8057
  assertValidDeclarativeHooksInConfig,
7593
8058
  assertValidNavigationSlugs,
7594
8059
  assertValidPreviewUrlsInConfig,
8060
+ assertValidTrashInConfig,
7595
8061
  availableWorkflowTransitions,
7596
8062
  buildSqlOrderBy,
7597
8063
  canViewWorkflowDraft,
@@ -7610,6 +8076,7 @@ export {
7610
8076
  count,
7611
8077
  createLifecycleEvent,
7612
8078
  createTaskRunner,
8079
+ createTrashPurgeTask,
7613
8080
  createWorkflowDocument,
7614
8081
  defaultValue,
7615
8082
  defineAction,
@@ -7621,6 +8088,8 @@ export {
7621
8088
  defineCardsView,
7622
8089
  defineCollection,
7623
8090
  defineConfig,
8091
+ defineCreatedAtField,
8092
+ defineCreatedByField,
7624
8093
  defineDateField,
7625
8094
  defineDateTimeField,
7626
8095
  defineEmailField,
@@ -7640,6 +8109,8 @@ export {
7640
8109
  defineRadioField,
7641
8110
  defineRelationshipField,
7642
8111
  defineRichTextField,
8112
+ defineRoles,
8113
+ defineRolesField,
7643
8114
  defineRowField,
7644
8115
  defineSelectField,
7645
8116
  defineTab,
@@ -7648,6 +8119,8 @@ export {
7648
8119
  defineTextField,
7649
8120
  defineTextareaField,
7650
8121
  defineTimeField,
8122
+ defineUpdatedAtField,
8123
+ defineUpdatedByField,
7651
8124
  defineUrlField,
7652
8125
  defineView,
7653
8126
  defineWorkspace,
@@ -7687,6 +8160,7 @@ export {
7687
8160
  getAdminRoleForCollection,
7688
8161
  getAllowedSitesForUser,
7689
8162
  getPublicAdminAuthConfig,
8163
+ getTrashEntryId,
7690
8164
  includes,
7691
8165
  initializeWorkflowDocument,
7692
8166
  isAICollection,
@@ -7730,14 +8204,18 @@ export {
7730
8204
  resolveAdminAuthCollection,
7731
8205
  resolveAllCollectionViews,
7732
8206
  resolveAuthorizedSiteId,
8207
+ resolveDocumentTitle,
7733
8208
  resolveIndexName,
7734
8209
  resolvePrefix,
8210
+ resolveTrashConfig,
8211
+ restoreDocument,
7735
8212
  round,
7736
8213
  runCollectionHooks,
7737
8214
  sanitizeDocForAI,
7738
8215
  saveWorkflowDraft,
7739
8216
  simplePublishingWorkflow,
7740
8217
  slugify,
8218
+ softDeleteDocument,
7741
8219
  sqrt,
7742
8220
  startsWith,
7743
8221
  sum,