@dyrected/core 2.18.0 → 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];
2944
2760
  }
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];
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;
2953
2783
  }
2954
- return curr === void 0 || curr === null ? fallback : curr;
2784
+ };
2785
+ function isConfigValidationError(error) {
2786
+ return error instanceof ConfigValidationError;
2955
2787
  }
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)) {
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) {
3005
2821
  try {
3006
- jexlInstance.addFunction(name, fn);
3007
- } catch {
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
+ }
3008
2855
  }
3009
- try {
3010
- jexlInstance.addTransform(name, fn);
3011
- } catch {
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
+ }
3012
2940
  }
3013
2941
  }
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 {
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
+ };
3077
+ }
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}`);
3083
+ }
3084
+ }
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
+ }
3099
+ }
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
+ }
3127
+ }
3128
+ }
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;
3148
- }
3149
- if (typeof candidate.value === "string" && candidate.value !== "null") {
3150
- roots.add(candidate.value);
3151
- }
3152
- 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 });
3153
3438
  }
3154
- if (Array.isArray(node)) {
3155
- for (const entry of node) collectRootIdentifiers(entry, roots, seen);
3156
- return;
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 });
3157
3446
  }
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);
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;
3165
3482
  }
3166
3483
  }
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));
3174
- }
3175
- function validateDeclarativeHookExpression(expression, surface, path) {
3176
- try {
3177
- const invalidRoots = getInvalidRoots(expression, SURFACE_ROOTS[surface]);
3178
- if (invalidRoots.length === 0) return [];
3179
- return [
3180
- {
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 [
3189
- {
3190
- path,
3191
- surface,
3192
- expression,
3193
- message: `has invalid syntax: ${error.message}`
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() } : {}
3194
3501
  }
3195
- ];
3502
+ });
3503
+ if (claimed.affectedRows !== 1) {
3504
+ return { name: task.name, status: "skipped", reason: "locked", durationMs: Date.now() - started };
3505
+ }
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 };
3196
3544
  }
3197
- }
3198
- function validateDeclarativeAccessExpression(expression, surface, path) {
3199
- try {
3200
- const invalidRoots = getInvalidRoots(expression, ACCESS_ROOTS);
3201
- if (invalidRoots.length === 0) return [];
3202
- return [
3203
- {
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 [
3212
- {
3213
- path,
3214
- surface,
3215
- expression,
3216
- message: `has invalid syntax: ${error.message}`
3217
- }
3218
- ];
3545
+ function track(work) {
3546
+ inFlight = inFlight.then(() => work).catch(() => void 0);
3547
+ return work;
3219
3548
  }
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 }));
3592
+ }
3593
+ };
3594
+ return runner;
3220
3595
  }
3221
- function validateAdminConditionExpression(expression, path, allowedRoots) {
3222
- try {
3223
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3224
- if (invalidRoots.length === 0) return [];
3225
- 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: [
3226
3614
  {
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 [
3615
+ name: "submit",
3616
+ label: "Submit for review",
3617
+ from: "draft",
3618
+ to: "in_review",
3619
+ requiredCapabilities: ["entry.submit"]
3620
+ },
3235
3621
  {
3236
- path,
3237
- surface: "field.admin.condition",
3238
- expression,
3239
- message: `has invalid syntax: ${error.message}`
3240
- }
3241
- ];
3242
- }
3243
- }
3244
- function validatePreviewUrlExpression(expression, path, allowedRoots) {
3245
- try {
3246
- const invalidRoots = getInvalidRoots(expression, allowedRoots);
3247
- if (invalidRoots.length === 0) return [];
3248
- return [
3622
+ name: "publish",
3623
+ label: "Publish",
3624
+ from: "in_review",
3625
+ to: "published",
3626
+ requiredCapabilities: ["entry.publish"]
3627
+ },
3249
3628
  {
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 [
3629
+ name: "reject",
3630
+ label: "Request changes",
3631
+ from: "in_review",
3632
+ to: "draft",
3633
+ requiredCapabilities: ["entry.publish"],
3634
+ requireComment: true
3635
+ },
3258
3636
  {
3259
- path,
3260
- surface: "collection.admin.previewUrl",
3261
- expression,
3262
- 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
3263
3643
  }
3264
- ];
3265
- }
3644
+ ],
3645
+ roles: [
3646
+ ...editors.map((role) => ({ role, capabilities: [...EDITOR_CAPABILITIES] })),
3647
+ ...publishers.map((role) => ({ role, capabilities: [...PUBLISHER_CAPABILITIES] }))
3648
+ ]
3649
+ };
3266
3650
  }
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}`);
3651
+ function publishingWorkflow() {
3652
+ return definePublishingWorkflow();
3271
3653
  }
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`);
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
+ };
3275
3670
  }
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
- }
3324
- });
3671
+ function publicMetadata(meta) {
3672
+ const { availableTransitions: _availableTransitions, ...safe } = meta;
3673
+ return safe;
3325
3674
  }
3326
- function collectFieldNames(fields) {
3327
- return fields.flatMap(
3328
- (field) => typeof field.name === "string" && field.name.length > 0 ? [field.name] : []
3329
- );
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;
3330
3682
  }
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);
3347
- }
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
- });
3358
- }
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));
3359
3700
  });
3360
3701
  }
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);
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;
3710
+ }
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;
3379
3733
  }
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
- });
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)
3389
3741
  }
3390
- });
3742
+ };
3391
3743
  }
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`
3744
+ function eventId() {
3745
+ return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3746
+ }
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;
3446
3848
  });
3447
- return issues;
3849
+ void dispatchLifecycleEvent(config, event);
3850
+ return { doc, event };
3448
3851
  }
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}`)
3455
- );
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
3463
- );
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;
3464
3869
  });
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
- );
3870
+ void dispatchLifecycleEvent(config, event);
3871
+ return { doc, event };
3872
+ }
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
+ })
3921
+ );
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
+ })
3931
+ );
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
+ });
3493
3941
  }
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
- );
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
+ });
3502
3966
  }
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
- );
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
+ });
3511
3999
  }
3512
- collectFieldAccessIssues(collection.fields, collectionPath, issues);
4000
+ return next;
3513
4001
  });
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
- );
3520
- }
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
- );
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
+ }
3529
4036
  }
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);
3554
- });
3555
- return issues;
3556
- }
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;
3574
- }
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);
4037
+ }
4038
+ return updated;
3586
4039
  }
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
3595
- }));
3596
- if (diagnostics.length === 0) return;
3597
- throw new ConfigValidationError(source, diagnostics);
4040
+
4041
+ // src/utils/admin-auth.ts
4042
+ function getAdminAuthCollection(config) {
4043
+ const adminCollection = resolveAdminAuthCollection(config.collections, config.adminAuth?.collectionSlug);
4044
+ return adminCollection ?? null;
3598
4045
  }
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
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);
4054
+ }
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
3631
4061
  }));
3632
- return [...hookIssues, ...accessIssues, ...adminConditionIssues, ...previewUrlIssues];
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
+ };
4069
+ }
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],
@@ -4355,412 +4857,212 @@ function parseSqlWhere(where, getJsonField, placeholder = "?", likeOperator = pl
4355
4857
  params.push(v);
4356
4858
  }
4357
4859
  return next();
4358
- });
4359
- return `${c} IN (${placeholders.join(", ")})`;
4360
- }
4361
- case "not_in": {
4362
- const vals = Array.isArray(operand) ? operand : [operand];
4363
- if (vals.length === 0) return "1=1";
4364
- const placeholders = vals.map((v) => {
4365
- params.push(typeof v === "boolean" && isJsonExtract ? String(v) : v);
4366
- return next();
4367
- });
4368
- return `${c} NOT IN (${placeholders.join(", ")})`;
4369
- }
4370
- case "gt":
4371
- case "greater_than":
4372
- params.push(operand);
4373
- return `${c} > ${next()}`;
4374
- case "gte":
4375
- case "greater_than_equal":
4376
- case "greater_than_or_equal":
4377
- params.push(operand);
4378
- return `${c} >= ${next()}`;
4379
- case "lt":
4380
- case "less_than":
4381
- params.push(operand);
4382
- return `${c} < ${next()}`;
4383
- case "lte":
4384
- case "less_than_equal":
4385
- case "less_than_or_equal":
4386
- params.push(operand);
4387
- return `${c} <= ${next()}`;
4388
- case "contains":
4389
- case "like":
4390
- params.push(`%${operand}%`);
4391
- return placeholder === "pg" ? `(${c})::text ${likeOperator} ${next()}` : `${c} ${likeOperator} ${next()}`;
4392
- case "starts_with":
4393
- params.push(`${operand}%`);
4394
- return placeholder === "pg" ? `(${c})::text ${likeOperator} ${next()}` : `${c} ${likeOperator} ${next()}`;
4395
- case "exists":
4396
- return operand ? `${c} IS NOT NULL` : `${c} IS NULL`;
4397
- default:
4398
- return assertNever(op, "parseSqlWhere");
4399
- }
4400
- }
4401
- function buildClause(w) {
4402
- const parts = [];
4403
- for (const [field, value] of Object.entries(w)) {
4404
- const upperField = field.toUpperCase();
4405
- if (upperField === "OR" && Array.isArray(value)) {
4406
- const sub = value.map((v) => `(${buildClause(v)})`).join(" OR ");
4407
- parts.push(`(${sub})`);
4408
- } else if (upperField === "AND" && Array.isArray(value)) {
4409
- const sub = value.map((v) => `(${buildClause(v)})`).join(" AND ");
4410
- parts.push(`(${sub})`);
4411
- } else {
4412
- parts.push(buildOperator(field, value));
4413
- }
4414
- }
4415
- return parts.length ? parts.join(" AND ") : "1=1";
4416
- }
4417
- const sql = buildClause(where);
4418
- return { sql, params };
4419
- }
4420
- function parseMongoWhere(where) {
4421
- function buildOperator(field, value) {
4422
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
4423
- return { [field]: { $eq: value } };
4424
- }
4425
- const entries = Object.entries(value);
4426
- if (entries.length !== 1) {
4427
- const merged = {};
4428
- for (const [op2, operand2] of entries) {
4429
- Object.assign(merged, buildSingleOp(field, op2, operand2)[field]);
4430
- }
4431
- return { [field]: merged };
4432
- }
4433
- const [op, operand] = entries[0];
4434
- return buildSingleOp(field, op, operand);
4435
- }
4436
- function buildSingleOp(field, op, operand) {
4437
- switch (op) {
4438
- case "equals":
4439
- return { [field]: { $eq: operand } };
4440
- case "not_equals":
4441
- return { [field]: { $ne: operand } };
4442
- case "in":
4443
- return { [field]: { $in: Array.isArray(operand) ? operand : [operand] } };
4444
- case "not_in":
4445
- return { [field]: { $nin: Array.isArray(operand) ? operand : [operand] } };
4860
+ });
4861
+ return `${c} IN (${placeholders.join(", ")})`;
4862
+ }
4863
+ case "not_in": {
4864
+ const vals = Array.isArray(operand) ? operand : [operand];
4865
+ if (vals.length === 0) return "1=1";
4866
+ const placeholders = vals.map((v) => {
4867
+ params.push(typeof v === "boolean" && isJsonExtract ? String(v) : v);
4868
+ return next();
4869
+ });
4870
+ return `${c} NOT IN (${placeholders.join(", ")})`;
4871
+ }
4446
4872
  case "gt":
4447
4873
  case "greater_than":
4448
- return { [field]: { $gt: operand } };
4874
+ params.push(operand);
4875
+ return `${c} > ${next()}`;
4449
4876
  case "gte":
4450
4877
  case "greater_than_equal":
4451
4878
  case "greater_than_or_equal":
4452
- return { [field]: { $gte: operand } };
4879
+ params.push(operand);
4880
+ return `${c} >= ${next()}`;
4453
4881
  case "lt":
4454
4882
  case "less_than":
4455
- return { [field]: { $lt: operand } };
4883
+ params.push(operand);
4884
+ return `${c} < ${next()}`;
4456
4885
  case "lte":
4457
4886
  case "less_than_equal":
4458
4887
  case "less_than_or_equal":
4459
- return { [field]: { $lte: operand } };
4888
+ params.push(operand);
4889
+ return `${c} <= ${next()}`;
4460
4890
  case "contains":
4461
4891
  case "like":
4462
- return { [field]: { $regex: operand, $options: "i" } };
4892
+ params.push(`%${operand}%`);
4893
+ return placeholder === "pg" ? `(${c})::text ${likeOperator} ${next()}` : `${c} ${likeOperator} ${next()}`;
4463
4894
  case "starts_with":
4464
- return { [field]: { $regex: `^${operand}`, $options: "i" } };
4895
+ params.push(`${operand}%`);
4896
+ return placeholder === "pg" ? `(${c})::text ${likeOperator} ${next()}` : `${c} ${likeOperator} ${next()}`;
4465
4897
  case "exists":
4466
- return { [field]: { $exists: operand } };
4898
+ return operand ? `${c} IS NOT NULL` : `${c} IS NULL`;
4467
4899
  default:
4468
- return assertNever(op, "parseMongoWhere");
4900
+ return assertNever(op, "parseSqlWhere");
4469
4901
  }
4470
4902
  }
4471
4903
  function buildClause(w) {
4472
- const conditions = [];
4904
+ const parts = [];
4473
4905
  for (const [field, value] of Object.entries(w)) {
4474
4906
  const upperField = field.toUpperCase();
4475
4907
  if (upperField === "OR" && Array.isArray(value)) {
4476
- conditions.push({ $or: value.map(buildClause) });
4908
+ const sub = value.map((v) => `(${buildClause(v)})`).join(" OR ");
4909
+ parts.push(`(${sub})`);
4477
4910
  } else if (upperField === "AND" && Array.isArray(value)) {
4478
- conditions.push({ $and: value.map(buildClause) });
4479
- } else {
4480
- conditions.push(buildOperator(field, value));
4481
- }
4482
- }
4483
- if (conditions.length === 0) return {};
4484
- if (conditions.length === 1) return conditions[0];
4485
- return { $and: conditions };
4486
- }
4487
- return buildClause(where);
4488
- }
4489
- function coerceBooleanWhere(where, fields) {
4490
- const booleanFields = new Set(
4491
- (fields ?? []).filter((f) => f.type === "boolean" && f.name).map((f) => f.name)
4492
- );
4493
- if (booleanFields.size === 0) return where;
4494
- const toBool = (v) => v === "true" ? true : v === "false" ? false : v;
4495
- const walk = (node) => {
4496
- if (Array.isArray(node)) return node.map(walk);
4497
- if (!node || typeof node !== "object") return node;
4498
- const out = {};
4499
- for (const [key, value] of Object.entries(node)) {
4500
- if (key.toUpperCase() === "AND" || key.toUpperCase() === "OR") {
4501
- out[key] = walk(value);
4502
- } else if (booleanFields.has(key)) {
4503
- if (value && typeof value === "object" && !Array.isArray(value)) {
4504
- out[key] = Object.fromEntries(
4505
- Object.entries(value).map(([op, operand]) => [
4506
- op,
4507
- Array.isArray(operand) ? operand.map(toBool) : toBool(operand)
4508
- ])
4509
- );
4510
- } else {
4511
- out[key] = toBool(value);
4512
- }
4513
- } else {
4514
- out[key] = value;
4515
- }
4516
- }
4517
- return out;
4518
- };
4519
- return walk(where);
4520
- }
4521
-
4522
- // src/utils/parse-sort.ts
4523
- var SORT_FIELD_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(ASC|DESC))?$/i;
4524
- function parseSort(sort, fallback = { field: "createdAt", direction: "DESC" }) {
4525
- if (!sort) return [fallback];
4526
- const clauses = sort.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
4527
- const descByPrefix = part.startsWith("-");
4528
- const withoutPrefix = descByPrefix ? part.slice(1).trim() : part;
4529
- const match = withoutPrefix.match(SORT_FIELD_PATTERN);
4530
- if (!match) return null;
4531
- return {
4532
- field: match[1],
4533
- direction: descByPrefix || match[2]?.toUpperCase() === "DESC" ? "DESC" : "ASC"
4534
- };
4535
- }).filter((clause) => Boolean(clause));
4536
- return clauses.length > 0 ? clauses : [fallback];
4537
- }
4538
- function numericFieldNames(fields) {
4539
- const names = /* @__PURE__ */ new Set();
4540
- for (const field of fields ?? []) {
4541
- if (field.type === "number" && "name" in field && field.name) {
4542
- names.add(field.name);
4543
- }
4544
- }
4545
- return names;
4546
- }
4547
- var TIMESTAMP_COLUMNS = {
4548
- createdat: "created_at",
4549
- created_at: "created_at",
4550
- updatedat: "updated_at",
4551
- updated_at: "updated_at"
4552
- };
4553
- function buildSqlOrderBy(sort, existingColumns, numericFields, dialect) {
4554
- return parseSort(sort).map(({ field, direction }) => {
4555
- const timestamp = TIMESTAMP_COLUMNS[field.toLowerCase()];
4556
- if (timestamp) return `${dialect.quoteIdent(timestamp)} ${direction}`;
4557
- if (existingColumns.includes(field) && field !== "id" && field !== "data") {
4558
- return `${dialect.quoteIdent(field)} ${direction}`;
4559
- }
4560
- const extracted = dialect.jsonExtract(field);
4561
- const expr2 = numericFields.has(field) ? dialect.castNumeric(extracted) : extracted;
4562
- return `${expr2} ${direction}`;
4563
- }).join(", ");
4564
- }
4565
-
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);
4911
+ const sub = value.map((v) => `(${buildClause(v)})`).join(" AND ");
4912
+ parts.push(`(${sub})`);
4606
4913
  } else {
4607
- throw err;
4914
+ parts.push(buildOperator(field, value));
4608
4915
  }
4609
4916
  }
4917
+ return parts.length ? parts.join(" AND ") : "1=1";
4610
4918
  }
4611
- return currentPayload;
4919
+ const sql = buildClause(where);
4920
+ return { sql, params };
4612
4921
  }
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;
4922
+ function parseMongoWhere(where) {
4923
+ function buildOperator(field, value) {
4924
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
4925
+ return { [field]: { $eq: value } };
4926
+ }
4927
+ const entries = Object.entries(value);
4928
+ if (entries.length !== 1) {
4929
+ const merged = {};
4930
+ for (const [op2, operand2] of entries) {
4931
+ Object.assign(merged, buildSingleOp(field, op2, operand2)[field]);
4693
4932
  }
4933
+ return { [field]: merged };
4694
4934
  }
4935
+ const [op, operand] = entries[0];
4936
+ return buildSingleOp(field, op, operand);
4695
4937
  }
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);
4938
+ function buildSingleOp(field, op, operand) {
4939
+ switch (op) {
4940
+ case "equals":
4941
+ return { [field]: { $eq: operand } };
4942
+ case "not_equals":
4943
+ return { [field]: { $ne: operand } };
4944
+ case "in":
4945
+ return { [field]: { $in: Array.isArray(operand) ? operand : [operand] } };
4946
+ case "not_in":
4947
+ return { [field]: { $nin: Array.isArray(operand) ? operand : [operand] } };
4948
+ case "gt":
4949
+ case "greater_than":
4950
+ return { [field]: { $gt: operand } };
4951
+ case "gte":
4952
+ case "greater_than_equal":
4953
+ case "greater_than_or_equal":
4954
+ return { [field]: { $gte: operand } };
4955
+ case "lt":
4956
+ case "less_than":
4957
+ return { [field]: { $lt: operand } };
4958
+ case "lte":
4959
+ case "less_than_equal":
4960
+ case "less_than_or_equal":
4961
+ return { [field]: { $lte: operand } };
4962
+ case "contains":
4963
+ case "like":
4964
+ return { [field]: { $regex: operand, $options: "i" } };
4965
+ case "starts_with":
4966
+ return { [field]: { $regex: `^${operand}`, $options: "i" } };
4967
+ case "exists":
4968
+ return { [field]: { $exists: operand } };
4969
+ default:
4970
+ return assertNever(op, "parseMongoWhere");
4971
+ }
4972
+ }
4973
+ function buildClause(w) {
4974
+ const conditions = [];
4975
+ for (const [field, value] of Object.entries(w)) {
4976
+ const upperField = field.toUpperCase();
4977
+ if (upperField === "OR" && Array.isArray(value)) {
4978
+ conditions.push({ $or: value.map(buildClause) });
4979
+ } else if (upperField === "AND" && Array.isArray(value)) {
4980
+ conditions.push({ $and: value.map(buildClause) });
4981
+ } else {
4982
+ conditions.push(buildOperator(field, value));
4715
4983
  }
4716
- result[field.name] = updatedValue;
4717
4984
  }
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
4985
+ if (conditions.length === 0) return {};
4986
+ if (conditions.length === 1) return conditions[0];
4987
+ return { $and: conditions };
4988
+ }
4989
+ return buildClause(where);
4990
+ }
4991
+ function coerceBooleanWhere(where, fields) {
4992
+ const booleanFields = new Set(
4993
+ (fields ?? []).filter((f) => f.type === "boolean" && f.name).map((f) => f.name)
4994
+ );
4995
+ if (booleanFields.size === 0) return where;
4996
+ const toBool = (v) => v === "true" ? true : v === "false" ? false : v;
4997
+ const walk = (node) => {
4998
+ if (Array.isArray(node)) return node.map(walk);
4999
+ if (!node || typeof node !== "object") return node;
5000
+ const out = {};
5001
+ for (const [key, value] of Object.entries(node)) {
5002
+ if (key.toUpperCase() === "AND" || key.toUpperCase() === "OR") {
5003
+ out[key] = walk(value);
5004
+ } else if (booleanFields.has(key)) {
5005
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5006
+ out[key] = Object.fromEntries(
5007
+ Object.entries(value).map(([op, operand]) => [
5008
+ op,
5009
+ Array.isArray(operand) ? operand.map(toBool) : toBool(operand)
5010
+ ])
4745
5011
  );
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
- }
5012
+ } else {
5013
+ out[key] = toBool(value);
4758
5014
  }
4759
- result[field.name] = blocksResult;
5015
+ } else {
5016
+ out[key] = value;
4760
5017
  }
4761
5018
  }
5019
+ return out;
5020
+ };
5021
+ return walk(where);
5022
+ }
5023
+
5024
+ // src/utils/parse-sort.ts
5025
+ var SORT_FIELD_PATTERN = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(ASC|DESC))?$/i;
5026
+ function parseSort(sort, fallback = { field: "createdAt", direction: "DESC" }) {
5027
+ if (!sort) return [fallback];
5028
+ const clauses = sort.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
5029
+ const descByPrefix = part.startsWith("-");
5030
+ const withoutPrefix = descByPrefix ? part.slice(1).trim() : part;
5031
+ const match = withoutPrefix.match(SORT_FIELD_PATTERN);
5032
+ if (!match) return null;
5033
+ return {
5034
+ field: match[1],
5035
+ direction: descByPrefix || match[2]?.toUpperCase() === "DESC" ? "DESC" : "ASC"
5036
+ };
5037
+ }).filter((clause) => Boolean(clause));
5038
+ return clauses.length > 0 ? clauses : [fallback];
5039
+ }
5040
+ function numericFieldNames(fields) {
5041
+ const names = /* @__PURE__ */ new Set();
5042
+ for (const field of fields ?? []) {
5043
+ if (field.type === "number" && "name" in field && field.name) {
5044
+ names.add(field.name);
5045
+ }
4762
5046
  }
4763
- return result;
5047
+ return names;
5048
+ }
5049
+ var TIMESTAMP_COLUMNS = {
5050
+ createdat: "created_at",
5051
+ created_at: "created_at",
5052
+ updatedat: "updated_at",
5053
+ updated_at: "updated_at"
5054
+ };
5055
+ function buildSqlOrderBy(sort, existingColumns, numericFields, dialect) {
5056
+ return parseSort(sort).map(({ field, direction }) => {
5057
+ const timestamp = TIMESTAMP_COLUMNS[field.toLowerCase()];
5058
+ if (timestamp) return `${dialect.quoteIdent(timestamp)} ${direction}`;
5059
+ if (existingColumns.includes(field) && field !== "id" && field !== "data") {
5060
+ return `${dialect.quoteIdent(field)} ${direction}`;
5061
+ }
5062
+ const extracted = dialect.jsonExtract(field);
5063
+ const expr2 = numericFields.has(field) ? dialect.castNumeric(extracted) : extracted;
5064
+ return `${expr2} ${direction}`;
5065
+ }).join(", ");
4764
5066
  }
4765
5067
 
4766
5068
  // src/utils/openapi.ts
@@ -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();
@@ -7470,6 +7806,28 @@ function pruneNavigationForUser(tree, user) {
7470
7806
  ungrouped: tree.ungrouped.filter(isAllowed)
7471
7807
  };
7472
7808
  }
7809
+ function resolveAllCollectionViews(collection, config) {
7810
+ const views = [...collection.views ?? []];
7811
+ const seenSlugs = new Set(views.map((v) => v.slug));
7812
+ const explicitNav = config?.admin?.navigation;
7813
+ if (Array.isArray(explicitNav)) {
7814
+ for (const item of explicitNav) {
7815
+ if (!item) continue;
7816
+ const itemCol = item.collection || item.addToCollection;
7817
+ if (Array.isArray(item.views)) {
7818
+ for (const view of item.views) {
7819
+ if (!view || !view.slug) continue;
7820
+ const targetCol = view.collection || itemCol;
7821
+ if (targetCol === collection.slug && !seenSlugs.has(view.slug)) {
7822
+ views.push(view);
7823
+ seenSlugs.add(view.slug);
7824
+ }
7825
+ }
7826
+ }
7827
+ }
7828
+ }
7829
+ return views;
7830
+ }
7473
7831
 
7474
7832
  // src/index.ts
7475
7833
  import { z } from "zod";
@@ -7523,6 +7881,133 @@ function defineImageField(config) {
7523
7881
  function defineJoinField(config) {
7524
7882
  return _defineJoinFieldBase(config);
7525
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;
7526
8011
  function defineTab(args) {
7527
8012
  return args.fields.map((field) => ({
7528
8013
  ...field,
@@ -7557,6 +8042,8 @@ export {
7557
8042
  RESERVED_NAVIGATION_SLUGS,
7558
8043
  TASK_LOCKS_COLLECTION,
7559
8044
  TASK_LOCKS_COLLECTION_CONFIG,
8045
+ TRASH_COLLECTION,
8046
+ TRASH_COLLECTION_CONFIG,
7560
8047
  UNASSIGNED_GROUP_KEY,
7561
8048
  ValidationError,
7562
8049
  WORKFLOW_HISTORY_COLLECTION,
@@ -7570,6 +8057,7 @@ export {
7570
8057
  assertValidDeclarativeHooksInConfig,
7571
8058
  assertValidNavigationSlugs,
7572
8059
  assertValidPreviewUrlsInConfig,
8060
+ assertValidTrashInConfig,
7573
8061
  availableWorkflowTransitions,
7574
8062
  buildSqlOrderBy,
7575
8063
  canViewWorkflowDraft,
@@ -7588,6 +8076,7 @@ export {
7588
8076
  count,
7589
8077
  createLifecycleEvent,
7590
8078
  createTaskRunner,
8079
+ createTrashPurgeTask,
7591
8080
  createWorkflowDocument,
7592
8081
  defaultValue,
7593
8082
  defineAction,
@@ -7599,6 +8088,8 @@ export {
7599
8088
  defineCardsView,
7600
8089
  defineCollection,
7601
8090
  defineConfig,
8091
+ defineCreatedAtField,
8092
+ defineCreatedByField,
7602
8093
  defineDateField,
7603
8094
  defineDateTimeField,
7604
8095
  defineEmailField,
@@ -7618,6 +8109,8 @@ export {
7618
8109
  defineRadioField,
7619
8110
  defineRelationshipField,
7620
8111
  defineRichTextField,
8112
+ defineRoles,
8113
+ defineRolesField,
7621
8114
  defineRowField,
7622
8115
  defineSelectField,
7623
8116
  defineTab,
@@ -7626,6 +8119,8 @@ export {
7626
8119
  defineTextField,
7627
8120
  defineTextareaField,
7628
8121
  defineTimeField,
8122
+ defineUpdatedAtField,
8123
+ defineUpdatedByField,
7629
8124
  defineUrlField,
7630
8125
  defineView,
7631
8126
  defineWorkspace,
@@ -7665,6 +8160,7 @@ export {
7665
8160
  getAdminRoleForCollection,
7666
8161
  getAllowedSitesForUser,
7667
8162
  getPublicAdminAuthConfig,
8163
+ getTrashEntryId,
7668
8164
  includes,
7669
8165
  initializeWorkflowDocument,
7670
8166
  isAICollection,
@@ -7706,15 +8202,20 @@ export {
7706
8202
  registerJexlHelpers,
7707
8203
  replace,
7708
8204
  resolveAdminAuthCollection,
8205
+ resolveAllCollectionViews,
7709
8206
  resolveAuthorizedSiteId,
8207
+ resolveDocumentTitle,
7710
8208
  resolveIndexName,
7711
8209
  resolvePrefix,
8210
+ resolveTrashConfig,
8211
+ restoreDocument,
7712
8212
  round,
7713
8213
  runCollectionHooks,
7714
8214
  sanitizeDocForAI,
7715
8215
  saveWorkflowDraft,
7716
8216
  simplePublishingWorkflow,
7717
8217
  slugify,
8218
+ softDeleteDocument,
7718
8219
  sqrt,
7719
8220
  startsWith,
7720
8221
  sum,