domma-cms 0.33.1 → 0.34.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.
@@ -5,13 +5,6 @@
5
5
  </button>
6
6
  </div>
7
7
 
8
- <div id="views-pro-notice" class="card mb-3" style="display:none;">
9
- <div class="card-body" style="color:var(--dm-warning,#f59e0b);display:flex;align-items:center;gap:.6rem;">
10
- <span data-icon="warning"></span>
11
- <span>Views require a MongoDB connection (pro mode). Configure one under Collections → Options.</span>
12
- </div>
13
- </div>
14
-
15
8
  <div class="card">
16
9
  <div class="card-body">
17
10
  <div id="views-table"></div>
@@ -1 +1 @@
1
- import{api as l}from"../api.js";import{filterByProject as p,getProjectFromHash as m}from"../lib/project-context.js";function i(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(s){await d(s),s.find("#create-view-btn").off("click").on("click",()=>{R.navigate("/views/new")}),Domma.icons.scan()}};async function d(s){let r=[];try{r=await l.views.list(),s.find("#views-pro-notice").hide()}catch(e){e.message?.includes("MongoDB")||e.message?.includes("pro mode")||e.message?.includes("connection")?s.find("#views-pro-notice").show():E.toast("Could not load views.",{type:"error"})}const c=m();c&&(r=p(r,c)),T.create("#views-table",{data:r,columns:[{key:"title",title:"Title",render:(e,t)=>{const n=document.createElement("a");return n.href=`#/views/${i(t.slug)}/preview`,n.textContent=e,n.style.fontWeight="600",n.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${i(e)}</code>`},{key:"pipeline",title:"Source",render:e=>`<code>${i(e?.source||"\u2014")}</code>`},{key:"display",title:"Mode",render:e=>i(e?.mode||"table")},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(t=>`<span class="badge badge-secondary">${i(t)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const t=document.createElement("div");t.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const n=document.createElement("a");n.href=`#/views/${i(e)}/preview`,n.className="btn btn-sm btn-ghost",n.setAttribute("data-tooltip","Preview"),n.innerHTML='<span data-icon="eye"></span>';const o=document.createElement("a");o.href=`#/views/edit/${i(e)}`,o.className="btn btn-sm btn-ghost",o.setAttribute("data-tooltip","Edit"),o.innerHTML='<span data-icon="edit"></span>';const a=document.createElement("button");return a.className="btn btn-sm btn-danger js-delete-view",a.dataset.slug=e,a.setAttribute("data-tooltip","Delete"),a.innerHTML='<span data-icon="trash"></span>',t.appendChild(n),t.appendChild(o),t.appendChild(a),t.outerHTML}}],emptyMessage:'No views yet. Click "New View" to create your first view.'}),document.querySelectorAll(".js-delete-view").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.slug;if(await E.confirm(`Delete view "${t}"? This cannot be undone.`))try{await l.views.delete(t),E.toast("View deleted.",{type:"success"}),await d(s)}catch{E.toast("Failed to delete view.",{type:"error"})}})}),Domma.icons.scan(),document.querySelectorAll("#views-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}
1
+ import{api as l}from"../api.js";import{filterByProject as p,getProjectFromHash as m}from"../lib/project-context.js";function a(i){return String(i).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}export const viewsListView={templateUrl:"/admin/js/templates/views-list.html",async onMount(i){await d(i),i.find("#create-view-btn").off("click").on("click",()=>{R.navigate("/views/new")}),Domma.icons.scan()}};async function d(i){let r=[];try{r=await l.views.list()}catch{E.toast("Could not load views.",{type:"error"})}const c=m();c&&(r=p(r,c)),T.create("#views-table",{data:r,columns:[{key:"title",title:"Title",render:(e,t)=>{const n=document.createElement("a");return n.href=`#/views/${a(t.slug)}/preview`,n.textContent=e,n.style.fontWeight="600",n.outerHTML}},{key:"slug",title:"Slug",render:e=>`<code>${a(e)}</code>`},{key:"pipeline",title:"Source",render:e=>`<code>${a(e?.source||"\u2014")}</code>`},{key:"display",title:"Mode",render:e=>a(e?.mode||"table")},{key:"access",title:"Roles",render:e=>(e?.roles||[]).map(t=>`<span class="badge badge-secondary">${a(t)}</span>`).join(" ")},{key:"slug",title:"Actions",render:e=>{const t=document.createElement("div");t.style.cssText="display:flex;gap:.4rem;justify-content:flex-end;";const n=document.createElement("a");n.href=`#/views/${a(e)}/preview`,n.className="btn btn-sm btn-ghost",n.setAttribute("data-tooltip","Preview"),n.innerHTML='<span data-icon="eye"></span>';const s=document.createElement("a");s.href=`#/views/edit/${a(e)}`,s.className="btn btn-sm btn-ghost",s.setAttribute("data-tooltip","Edit"),s.innerHTML='<span data-icon="edit"></span>';const o=document.createElement("button");return o.className="btn btn-sm btn-danger js-delete-view",o.dataset.slug=e,o.setAttribute("data-tooltip","Delete"),o.innerHTML='<span data-icon="trash"></span>',t.appendChild(n),t.appendChild(s),t.appendChild(o),t.outerHTML}}],emptyMessage:'No views yet. Click "New View" to create your first view.'}),document.querySelectorAll(".js-delete-view").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.slug;if(await E.confirm(`Delete view "${t}"? This cannot be undone.`))try{await l.views.delete(t),E.toast("View deleted.",{type:"success"}),await d(i)}catch{E.toast("Failed to delete view.",{type:"error"})}})}),Domma.icons.scan(),document.querySelectorAll("#views-table [data-tooltip]").forEach(e=>{E.tooltip(e,{content:e.getAttribute("data-tooltip"),position:"top"})})}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Views API (Pro — requires MongoDB)
2
+ * Views API
3
3
  *
4
4
  * Admin endpoints (authenticated + views permission):
5
5
  * GET /views - List all view configs
@@ -678,7 +678,7 @@ async function processViewBlocks(markdown, tagSet) {
678
678
  replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts);
679
679
  }
680
680
  } catch {
681
- // View not found, MongoDB not configured, or pipeline error — show empty state
681
+ // View not found or pipeline error — show empty state
682
682
  }
683
683
 
684
684
  if (attrs.class || attrs.id) {
@@ -958,7 +958,7 @@ async function renderViewBrowserShell(attrs) {
958
958
  total = r.total ?? entries.length;
959
959
  }
960
960
  } catch {
961
- // executeView errored (no Pro / view missing) — emit empty shell.
961
+ // executeView errored (view missing or pipeline error) — emit empty shell.
962
962
  }
963
963
 
964
964
  // Derive a schema-like fields list from the first row so the browser can
@@ -0,0 +1,609 @@
1
+ /**
2
+ * View Pipeline Evaluator
3
+ *
4
+ * Executes the Views feature's allowed aggregation-stage subset in plain
5
+ * JavaScript so views work on file-backed collections (no MongoDB needed).
6
+ * MongoDB-backed sources keep using native aggregation in views.js — this
7
+ * module exists so the SAME view definition produces the same results on
8
+ * either storage adapter. Documents are the raw entry shape ({id, data,
9
+ * meta}) and field paths are dot-paths ('data.price', 'meta.createdAt').
10
+ *
11
+ * Deliberate divergences from MongoDB (documented, pragmatic):
12
+ * - Equality is loose-numeric: the guided view editor emits string values
13
+ * even for numeric fields, and strict typing would silently empty views
14
+ * that visibly "should" match. (Same trade filterEngine makes.)
15
+ * - $sort places missing/null values LAST regardless of direction (Mongo
16
+ * treats missing as lowest).
17
+ * - $addFields supports literals and '$path' references only — operator
18
+ * expressions throw a clear error rather than mis-evaluating.
19
+ *
20
+ * Pure module: no imports from other services. $lookup resolves foreign
21
+ * collections through an injected `resolveCollection(slug)` callback.
22
+ */
23
+
24
+ /** Error raised for constructs the evaluator does not support. */
25
+ export class ViewPipelineError extends Error {
26
+ /**
27
+ * @param {string} message
28
+ * @param {string} [stage] - The stage type that raised the error
29
+ */
30
+ constructor(message, stage = '') {
31
+ super(message);
32
+ this.name = 'ViewPipelineError';
33
+ this.stage = stage;
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Value helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Path segments that must never be traversed or written: assigning through
43
+ * them on plain objects mutates shared prototypes in a long-running server
44
+ * process. Stage configs are admin-authored JSON, but a typo'd or malicious
45
+ * '__proto__.x' output path must be inert, not global.
46
+ */
47
+ const UNSAFE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
48
+
49
+ function splitPath(path) {
50
+ return String(path).split('.');
51
+ }
52
+
53
+ /**
54
+ * Resolve a dot-path against a document. Returns undefined through nulls
55
+ * and refuses to read through prototype-chain segments.
56
+ *
57
+ * @param {object} doc
58
+ * @param {string} path
59
+ * @returns {*}
60
+ */
61
+ function getPath(doc, path) {
62
+ let value = doc;
63
+ for (const part of splitPath(path)) {
64
+ if (UNSAFE_SEGMENTS.has(part)) return undefined;
65
+ if (value === null || value === undefined) return undefined;
66
+ value = value[part];
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Set a dot-path on a plain object, creating intermediate objects.
73
+ * Prototype-chain segments throw rather than silently polluting.
74
+ *
75
+ * @param {object} target
76
+ * @param {string} path
77
+ * @param {*} value
78
+ */
79
+ function setPath(target, path, value) {
80
+ const parts = splitPath(path);
81
+ if (parts.some((p) => UNSAFE_SEGMENTS.has(p))) {
82
+ throw new ViewPipelineError(`"${path}" is not a valid output field path`);
83
+ }
84
+ let node = target;
85
+ for (let i = 0; i < parts.length - 1; i++) {
86
+ if (typeof node[parts[i]] !== 'object' || node[parts[i]] === null || Array.isArray(node[parts[i]])) {
87
+ node[parts[i]] = {};
88
+ }
89
+ node = node[parts[i]];
90
+ }
91
+ node[parts[parts.length - 1]] = value;
92
+ }
93
+
94
+ /** Delete a dot-path from a plain object (used by $project exclusion). */
95
+ function deletePath(target, path) {
96
+ const parts = splitPath(path);
97
+ if (parts.some((p) => UNSAFE_SEGMENTS.has(p))) return;
98
+ let node = target;
99
+ for (let i = 0; i < parts.length - 1; i++) {
100
+ node = node?.[parts[i]];
101
+ if (typeof node !== 'object' || node === null) return;
102
+ }
103
+ if (typeof node === 'object' && node !== null) delete node[parts[parts.length - 1]];
104
+ }
105
+
106
+ /** Number('') is 0 — treat empty strings as non-numeric like filterEngine. */
107
+ function maybeNumber(value) {
108
+ if (typeof value === 'number') return value;
109
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
110
+ return Number(value);
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Loose scalar equality: strict first, then numeric coercion. Objects
117
+ * compare by JSON shape (covers $in of object values and $addToSet).
118
+ */
119
+ function scalarEq(a, b) {
120
+ if (a === b) return true;
121
+ const na = maybeNumber(a);
122
+ const nb = maybeNumber(b);
123
+ if (na !== null && nb !== null) return na === nb;
124
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
125
+ return JSON.stringify(a) === JSON.stringify(b);
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Mongo-style equality: if the document value is an array, a scalar query
132
+ * value matches when any element matches (array membership).
133
+ */
134
+ function looseEq(docValue, queryValue) {
135
+ if (Array.isArray(docValue) && !Array.isArray(queryValue)) {
136
+ return docValue.some((el) => scalarEq(el, queryValue));
137
+ }
138
+ return scalarEq(docValue, queryValue);
139
+ }
140
+
141
+ /** Ordering comparison with numeric coercion; undefined never matches. */
142
+ function compare(docValue, queryValue) {
143
+ if (docValue === undefined || docValue === null) return null;
144
+ const nd = maybeNumber(docValue);
145
+ const nq = maybeNumber(queryValue);
146
+ if (nd !== null && nq !== null) return nd < nq ? -1 : nd > nq ? 1 : 0;
147
+ const sd = String(docValue);
148
+ const sq = String(queryValue);
149
+ return sd < sq ? -1 : sd > sq ? 1 : 0;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // $match
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** True when a query fragment is an operator object ({$gt: 5, ...}). */
157
+ function isOperatorObject(value) {
158
+ return value !== null
159
+ && typeof value === 'object'
160
+ && !Array.isArray(value)
161
+ && Object.keys(value).length > 0
162
+ && Object.keys(value).every((k) => k.startsWith('$'));
163
+ }
164
+
165
+ /**
166
+ * Evaluate one field's condition (plain value or operator object) against
167
+ * a resolved document value.
168
+ */
169
+ function matchFieldCondition(docValue, condition) {
170
+ if (!isOperatorObject(condition)) {
171
+ return looseEq(docValue, condition);
172
+ }
173
+
174
+ for (const [op, arg] of Object.entries(condition)) {
175
+ switch (op) {
176
+ case '$eq':
177
+ if (!looseEq(docValue, arg)) return false;
178
+ break;
179
+ case '$ne':
180
+ if (looseEq(docValue, arg)) return false;
181
+ break;
182
+ case '$gt': {
183
+ const c = compare(docValue, arg);
184
+ if (c === null || c <= 0) return false;
185
+ break;
186
+ }
187
+ case '$gte': {
188
+ const c = compare(docValue, arg);
189
+ if (c === null || c < 0) return false;
190
+ break;
191
+ }
192
+ case '$lt': {
193
+ const c = compare(docValue, arg);
194
+ if (c === null || c >= 0) return false;
195
+ break;
196
+ }
197
+ case '$lte': {
198
+ const c = compare(docValue, arg);
199
+ if (c === null || c > 0) return false;
200
+ break;
201
+ }
202
+ case '$in':
203
+ if (!Array.isArray(arg)) throw new ViewPipelineError('$in requires an array', '$match');
204
+ if (!arg.some((v) => looseEq(docValue, v))) return false;
205
+ break;
206
+ case '$nin':
207
+ if (!Array.isArray(arg)) throw new ViewPipelineError('$nin requires an array', '$match');
208
+ if (arg.some((v) => looseEq(docValue, v))) return false;
209
+ break;
210
+ case '$exists':
211
+ if (arg ? docValue === undefined : docValue !== undefined) return false;
212
+ break;
213
+ case '$regex': {
214
+ let re;
215
+ try {
216
+ re = arg instanceof RegExp ? arg : new RegExp(arg, condition.$options || '');
217
+ } catch {
218
+ throw new ViewPipelineError(`Invalid $regex pattern: ${arg}`, '$match');
219
+ }
220
+ const tested = Array.isArray(docValue)
221
+ ? docValue.some((el) => re.test(String(el)))
222
+ : docValue !== undefined && docValue !== null && re.test(String(docValue));
223
+ if (!tested) return false;
224
+ break;
225
+ }
226
+ case '$options':
227
+ break; // consumed by $regex
228
+ case '$not':
229
+ if (matchFieldCondition(docValue, arg)) return false;
230
+ break;
231
+ case '$elemMatch': {
232
+ if (!Array.isArray(docValue)) return false;
233
+ const some = docValue.some((el) =>
234
+ isOperatorObject(arg) || Object.keys(arg || {}).every((k) => k.startsWith('$'))
235
+ ? matchFieldCondition(el, arg)
236
+ : matchDocument(el, arg));
237
+ if (!some) return false;
238
+ break;
239
+ }
240
+ default:
241
+ throw new ViewPipelineError(`"${op}" is not supported for file-backed collections`, '$match');
242
+ }
243
+ }
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * Evaluate a MongoDB query-document subset against a document.
249
+ * Exported for reuse and direct testing.
250
+ *
251
+ * @param {object} doc
252
+ * @param {object} query
253
+ * @returns {boolean}
254
+ */
255
+ export function matchDocument(doc, query) {
256
+ if (!query || typeof query !== 'object') return true;
257
+
258
+ for (const [key, condition] of Object.entries(query)) {
259
+ if (key === '$and') {
260
+ if (!Array.isArray(condition)) throw new ViewPipelineError('$and requires an array', '$match');
261
+ if (!condition.every((sub) => matchDocument(doc, sub))) return false;
262
+ continue;
263
+ }
264
+ if (key === '$or') {
265
+ if (!Array.isArray(condition)) throw new ViewPipelineError('$or requires an array', '$match');
266
+ if (!condition.some((sub) => matchDocument(doc, sub))) return false;
267
+ continue;
268
+ }
269
+ if (key === '$nor') {
270
+ if (!Array.isArray(condition)) throw new ViewPipelineError('$nor requires an array', '$match');
271
+ if (condition.some((sub) => matchDocument(doc, sub))) return false;
272
+ continue;
273
+ }
274
+ if (key.startsWith('$')) {
275
+ throw new ViewPipelineError(`"${key}" is not supported for file-backed collections`, '$match');
276
+ }
277
+ if (!matchFieldCondition(getPath(doc, key), condition)) return false;
278
+ }
279
+ return true;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Stage evaluators
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function evalSort(docs, config) {
287
+ const keys = Object.entries(config || {});
288
+ // Stable in V8; missing values sort last regardless of direction.
289
+ return [...docs].sort((a, b) => {
290
+ for (const [path, dirRaw] of keys) {
291
+ const dir = Number(dirRaw) < 0 ? -1 : 1;
292
+ const av = getPath(a, path);
293
+ const bv = getPath(b, path);
294
+ const aMissing = av === undefined || av === null;
295
+ const bMissing = bv === undefined || bv === null;
296
+ if (aMissing && bMissing) continue;
297
+ if (aMissing) return 1;
298
+ if (bMissing) return -1;
299
+ const na = maybeNumber(av);
300
+ const nb = maybeNumber(bv);
301
+ let c;
302
+ if (na !== null && nb !== null) c = na - nb;
303
+ else c = String(av).localeCompare(String(bv));
304
+ if (c !== 0) return (c < 0 ? -1 : 1) * dir;
305
+ }
306
+ return 0;
307
+ });
308
+ }
309
+
310
+ /** Resolve an $addFields / $project value: '$path' ref or literal. */
311
+ function resolveValueSpec(doc, spec, stage) {
312
+ if (typeof spec === 'string' && spec.startsWith('$')) {
313
+ return getPath(doc, spec.slice(1));
314
+ }
315
+ if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
316
+ if (Object.keys(spec).some((k) => k.startsWith('$'))) {
317
+ throw new ViewPipelineError(
318
+ `operator expressions are not supported for file-backed collections (${stage})`, stage);
319
+ }
320
+ }
321
+ return spec;
322
+ }
323
+
324
+ function evalProject(docs, config) {
325
+ const entries = Object.entries(config || {});
326
+ if (!entries.length) return docs;
327
+
328
+ const exclusions = entries.filter(([k, v]) => (v === 0 || v === false) && k !== '_id' && k !== 'id');
329
+ const inclusions = entries.filter(([k, v]) => !(v === 0 || v === false));
330
+
331
+ if (exclusions.length && inclusions.length) {
332
+ throw new ViewPipelineError('Cannot mix inclusion and exclusion in $project', '$project');
333
+ }
334
+
335
+ if (exclusions.length) {
336
+ return docs.map((doc) => {
337
+ const clone = structuredClone(doc);
338
+ for (const [path] of exclusions) deletePath(clone, path);
339
+ return clone;
340
+ });
341
+ }
342
+
343
+ return docs.map((doc) => {
344
+ const out = {};
345
+ // id carried unless explicitly excluded ({..., id: 0} — mongo's _id exception)
346
+ const idExcluded = entries.some(([k, v]) => (k === 'id' || k === '_id') && (v === 0 || v === false));
347
+ if (!idExcluded && doc.id !== undefined) out.id = doc.id;
348
+ for (const [path, spec] of inclusions) {
349
+ const value = (spec === 1 || spec === true)
350
+ ? getPath(doc, path)
351
+ : resolveValueSpec(doc, spec, '$project');
352
+ if (value !== undefined) setPath(out, path, value);
353
+ }
354
+ return out;
355
+ });
356
+ }
357
+
358
+ function evalAddFields(docs, config) {
359
+ const entries = Object.entries(config || {});
360
+ return docs.map((doc) => {
361
+ const clone = structuredClone(doc);
362
+ for (const [path, spec] of entries) {
363
+ setPath(clone, path, resolveValueSpec(doc, spec, '$addFields'));
364
+ }
365
+ return clone;
366
+ });
367
+ }
368
+
369
+ function evalUnwind(docs, config) {
370
+ let path;
371
+ let preserve = false;
372
+ if (typeof config === 'string') {
373
+ path = config;
374
+ } else if (config && typeof config === 'object') {
375
+ if (config.includeArrayIndex) {
376
+ throw new ViewPipelineError('includeArrayIndex is not supported for file-backed collections', '$unwind');
377
+ }
378
+ path = config.path;
379
+ preserve = !!config.preserveNullAndEmptyArrays;
380
+ }
381
+ if (typeof path !== 'string' || !path.startsWith('$')) {
382
+ throw new ViewPipelineError('$unwind requires a "$path" string', '$unwind');
383
+ }
384
+ const fieldPath = path.slice(1);
385
+
386
+ const out = [];
387
+ for (const doc of docs) {
388
+ const value = getPath(doc, fieldPath);
389
+ if (Array.isArray(value)) {
390
+ if (!value.length) {
391
+ if (preserve) {
392
+ const clone = structuredClone(doc);
393
+ deletePath(clone, fieldPath);
394
+ out.push(clone);
395
+ }
396
+ continue;
397
+ }
398
+ for (const el of value) {
399
+ const clone = structuredClone(doc);
400
+ setPath(clone, fieldPath, el);
401
+ out.push(clone);
402
+ }
403
+ } else if (value === undefined || value === null) {
404
+ if (preserve) {
405
+ const clone = structuredClone(doc);
406
+ deletePath(clone, fieldPath);
407
+ out.push(clone);
408
+ }
409
+ } else {
410
+ out.push(doc); // non-array scalar passes through (mongo semantics)
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+
416
+ function resolveGroupId(doc, idSpec) {
417
+ if (idSpec === null || idSpec === undefined) return null;
418
+ if (typeof idSpec === 'string' && idSpec.startsWith('$')) return getPath(doc, idSpec.slice(1));
419
+ if (typeof idSpec === 'object' && !Array.isArray(idSpec)) {
420
+ const out = {};
421
+ for (const [k, v] of Object.entries(idSpec)) {
422
+ out[k] = (typeof v === 'string' && v.startsWith('$')) ? getPath(doc, v.slice(1)) : v;
423
+ }
424
+ return out;
425
+ }
426
+ return idSpec; // literal
427
+ }
428
+
429
+ function evalGroup(docs, config) {
430
+ const { _id: idSpec = null, ...accums } = config || {};
431
+
432
+ // Validate accumulators up front
433
+ const ACCS = new Set(['$sum', '$avg', '$min', '$max', '$first', '$last', '$push', '$addToSet']);
434
+ for (const [field, spec] of Object.entries(accums)) {
435
+ if (!spec || typeof spec !== 'object' || Object.keys(spec).length !== 1) {
436
+ throw new ViewPipelineError(`$group field "${field}" must be a single accumulator object`, '$group');
437
+ }
438
+ const acc = Object.keys(spec)[0];
439
+ if (!ACCS.has(acc)) {
440
+ throw new ViewPipelineError(`"${acc}" is not supported for file-backed collections`, '$group');
441
+ }
442
+ }
443
+
444
+ const groups = new Map(); // key → { _id, docs: [] }
445
+ for (const doc of docs) {
446
+ const id = resolveGroupId(doc, idSpec);
447
+ const key = JSON.stringify(id ?? null);
448
+ if (!groups.has(key)) groups.set(key, { _id: id ?? null, docs: [] });
449
+ groups.get(key).docs.push(doc);
450
+ }
451
+
452
+ const resolveArg = (doc, arg) =>
453
+ (typeof arg === 'string' && arg.startsWith('$')) ? getPath(doc, arg.slice(1)) : arg;
454
+
455
+ const out = [];
456
+ for (const { _id, docs: members } of groups.values()) {
457
+ const row = { _id };
458
+ for (const [field, spec] of Object.entries(accums)) {
459
+ const [acc] = Object.keys(spec);
460
+ const arg = spec[acc];
461
+ switch (acc) {
462
+ case '$sum': {
463
+ let sum = 0;
464
+ for (const m of members) {
465
+ const v = maybeNumber(resolveArg(m, arg));
466
+ if (v !== null) sum += v;
467
+ }
468
+ row[field] = sum;
469
+ break;
470
+ }
471
+ case '$avg': {
472
+ const nums = members.map((m) => maybeNumber(resolveArg(m, arg))).filter((v) => v !== null);
473
+ row[field] = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
474
+ break;
475
+ }
476
+ case '$min':
477
+ case '$max': {
478
+ let best;
479
+ for (const m of members) {
480
+ const v = resolveArg(m, arg);
481
+ if (v === undefined || v === null) continue;
482
+ if (best === undefined) { best = v; continue; }
483
+ const c = compare(v, best);
484
+ if (c !== null && ((acc === '$min' && c < 0) || (acc === '$max' && c > 0))) best = v;
485
+ }
486
+ row[field] = best ?? null;
487
+ break;
488
+ }
489
+ case '$first':
490
+ row[field] = resolveArg(members[0], arg);
491
+ break;
492
+ case '$last':
493
+ row[field] = resolveArg(members[members.length - 1], arg);
494
+ break;
495
+ case '$push':
496
+ row[field] = members.map((m) => resolveArg(m, arg));
497
+ break;
498
+ case '$addToSet': {
499
+ const set = [];
500
+ for (const m of members) {
501
+ const v = resolveArg(m, arg);
502
+ if (!set.some((s) => scalarEq(s, v))) set.push(v);
503
+ }
504
+ row[field] = set;
505
+ break;
506
+ }
507
+ }
508
+ }
509
+ out.push(row);
510
+ }
511
+ return out;
512
+ }
513
+
514
+ async function evalLookup(docs, config, resolveCollection, lookupCache) {
515
+ const { from, localField, foreignField, as } = config || {};
516
+ if (!from || !localField || !foreignField || !as) {
517
+ throw new ViewPipelineError(
518
+ 'Only the equality form of $lookup ({from, localField, foreignField, as}) is supported for file-backed collections',
519
+ '$lookup');
520
+ }
521
+ if (typeof resolveCollection !== 'function') {
522
+ throw new ViewPipelineError('$lookup requires a collection resolver', '$lookup');
523
+ }
524
+
525
+ // Mongo-authored views reference `cms_<slug>` collection names — accept both.
526
+ const slug = String(from).replace(/^cms_/, '');
527
+ if (!lookupCache.has(slug)) {
528
+ lookupCache.set(slug, await resolveCollection(slug));
529
+ }
530
+ const foreign = lookupCache.get(slug) || [];
531
+
532
+ return docs.map((doc) => {
533
+ const localValue = getPath(doc, localField);
534
+ const matches = foreign.filter((f) => {
535
+ const fv = getPath(f, foreignField);
536
+ if (Array.isArray(localValue)) return localValue.some((lv) => looseEq(fv, lv));
537
+ return looseEq(fv, localValue) || looseEq(localValue, fv);
538
+ });
539
+ const clone = structuredClone(doc);
540
+ setPath(clone, as, structuredClone(matches));
541
+ return clone;
542
+ });
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Pipeline driver
547
+ // ---------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Execute a pipeline (mongo-form stage objects) over an array of documents.
551
+ * Returns the FULL result set — the caller paginates.
552
+ *
553
+ * @param {object[]} docs
554
+ * @param {object[]} stages - [{$match: {...}}, {$sort: {...}}, ...]
555
+ * @param {object} [opts]
556
+ * @param {function} [opts.resolveCollection] - async (slug) => object[] (for $lookup)
557
+ * @returns {Promise<object[]>}
558
+ * @throws {ViewPipelineError} On unsupported constructs
559
+ */
560
+ export async function executePipeline(docs, stages, { resolveCollection } = {}) {
561
+ let current = docs;
562
+ const lookupCache = new Map();
563
+
564
+ for (const stage of stages || []) {
565
+ const keys = Object.keys(stage || {});
566
+ if (keys.length !== 1) {
567
+ throw new ViewPipelineError('Each pipeline stage must have exactly one stage type');
568
+ }
569
+ const type = keys[0];
570
+ const config = stage[type];
571
+
572
+ switch (type) {
573
+ case '$match':
574
+ current = current.filter((doc) => matchDocument(doc, config));
575
+ break;
576
+ case '$sort':
577
+ current = evalSort(current, config);
578
+ break;
579
+ case '$project':
580
+ current = evalProject(current, config);
581
+ break;
582
+ case '$addFields':
583
+ current = evalAddFields(current, config);
584
+ break;
585
+ case '$unwind':
586
+ current = evalUnwind(current, config);
587
+ break;
588
+ case '$group':
589
+ current = evalGroup(current, config);
590
+ break;
591
+ case '$lookup':
592
+ current = await evalLookup(current, config, resolveCollection, lookupCache);
593
+ break;
594
+ case '$limit':
595
+ current = current.slice(0, Math.max(0, Number(config) || 0));
596
+ break;
597
+ case '$skip':
598
+ current = current.slice(Math.max(0, Number(config) || 0));
599
+ break;
600
+ case '$count':
601
+ current = [{ [String(config || 'count')]: current.length }];
602
+ break;
603
+ default:
604
+ throw new ViewPipelineError(`"${type}" is not supported for file-backed collections`, type);
605
+ }
606
+ }
607
+
608
+ return current;
609
+ }
@@ -1,27 +1,51 @@
1
1
  /**
2
- * Views Service (Pro — requires MongoDB)
2
+ * Views Service
3
3
  *
4
- * View configs are stored in MongoDB `cms__views` on the 'default' connection.
5
- * The `connection` field in each view config specifies which connection to use
6
- * when executing the aggregation pipeline.
4
+ * View configs are stored as JSON files in content/views/ (always file-based,
5
+ * like collection schemas and forms). Execution dispatches on the SOURCE
6
+ * collection's storage adapter:
7
7
  *
8
- * Allowed aggregation stage types (Phase 1):
8
+ * - mongodb-backed source with a live connection → native MongoDB
9
+ * aggregation (unchanged from the original implementation)
10
+ * - everything else → the built-in JS pipeline evaluator (viewPipeline.js)
11
+ * over the collection adapter's documents
12
+ *
13
+ * The same view definition therefore works on both storage adapters —
14
+ * field paths are raw-document dot-paths ('data.price', 'meta.createdAt')
15
+ * in both cases.
16
+ *
17
+ * Older installs that stored view configs in the MongoDB `cms__views`
18
+ * collection are migrated to files automatically (one-time, read-only —
19
+ * the Mongo collection is never modified).
20
+ *
21
+ * Allowed aggregation stage types:
9
22
  * $match, $lookup, $sort, $project, $unwind, $limit, $skip, $count,
10
23
  * $addFields, $group
11
24
  *
12
25
  * Forbidden stage types:
13
26
  * $out, $merge, $function, $accumulator, $graphLookup
14
27
  */
28
+ import fs from 'fs/promises';
29
+ import path from 'path';
30
+ import {fileURLToPath} from 'url';
15
31
  import {v4 as uuidv4} from 'uuid';
16
32
  import {buildRowLevelMatch} from './rowAccess.js';
33
+ import {executePipeline} from './viewPipeline.js';
17
34
  import * as cache from './cache/index.js';
18
35
 
19
- /** MongoDB collection where view configs are stored. */
20
- const VIEWS_COLLECTION = 'cms__views';
36
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
+ const ROOT = path.resolve(__dirname, '..', '..');
38
+ export const VIEWS_DIR = path.join(ROOT, 'content', 'views');
39
+
40
+ /** Legacy MongoDB collection where pre-0.34 view configs were stored. */
41
+ const LEGACY_VIEWS_COLLECTION = 'cms__views';
21
42
 
22
43
  /** Prefix applied to CMS data collections — mirrors MongoAdapter.PREFIX. */
23
44
  const COLLECTION_PREFIX = 'cms_';
24
45
 
46
+ /** Filename/lookup guard — view slugs are slugified, so keep this strict. */
47
+ const SLUG_RE = /^[a-z0-9-]+$/;
48
+
25
49
  const ALLOWED_STAGES = new Set([
26
50
  '$match', '$lookup', '$sort', '$project',
27
51
  '$unwind', '$limit', '$skip', '$count',
@@ -36,21 +60,6 @@ const FORBIDDEN_STAGES = new Set([
36
60
  // Internal helpers
37
61
  // ---------------------------------------------------------------------------
38
62
 
39
- /**
40
- * Get the Db instance for storing/reading view configs ('default' connection).
41
- *
42
- * @returns {Promise<import('mongodb').Db>}
43
- * @throws {Error} If MongoDB is not configured
44
- */
45
- async function getMetaDb() {
46
- try {
47
- const { getDb } = await import('./connectionManager.js');
48
- return getDb('default');
49
- } catch {
50
- throw new Error('Views require a MongoDB connection. Configure a "default" connection in pro mode.');
51
- }
52
- }
53
-
54
63
  /**
55
64
  * Slugify a string to a URL-safe identifier.
56
65
  *
@@ -89,22 +98,135 @@ function buildPipelineStages(stages) {
89
98
  return stages.map(stage => ({ [stage.type]: stage.config }));
90
99
  }
91
100
 
101
+ // ---------------------------------------------------------------------------
102
+ // File storage
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Ensure the views directory exists. Lazy — engine updates never touch
107
+ * content/, so the dir is created on first write.
108
+ *
109
+ * @returns {Promise<void>}
110
+ */
111
+ export async function ensureViewsDir() {
112
+ await fs.mkdir(VIEWS_DIR, { recursive: true });
113
+ }
114
+
115
+ /**
116
+ * Read a single view config by slug (no migration hook — internal/raw).
117
+ *
118
+ * @param {string} slug
119
+ * @returns {Promise<object>}
120
+ * @throws {Error} If the view file does not exist or cannot be parsed.
121
+ */
122
+ export async function readView(slug) {
123
+ const file = path.join(VIEWS_DIR, `${slug}.json`);
124
+ return JSON.parse(await fs.readFile(file, 'utf8'));
125
+ }
126
+
127
+ /**
128
+ * Write a view config to disk and invalidate its cache tag.
129
+ *
130
+ * @param {string} slug
131
+ * @param {object} view
132
+ * @returns {Promise<void>}
133
+ */
134
+ export async function writeView(slug, view) {
135
+ await ensureViewsDir();
136
+ const file = path.join(VIEWS_DIR, `${slug}.json`);
137
+ await fs.writeFile(file, JSON.stringify(view, null, 4) + '\n', 'utf8');
138
+ await cache.invalidateTags([`view:${slug}`]);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // One-time migration from the legacy MongoDB store
143
+ // ---------------------------------------------------------------------------
144
+
145
+ let migrationPromise = null;
146
+
147
+ function ensureMigrated() {
148
+ if (!migrationPromise) migrationPromise = migrateFromMongo().catch(() => {});
149
+ return migrationPromise;
150
+ }
151
+
152
+ /**
153
+ * Migrate pre-0.34 view configs from the MongoDB `cms__views` collection to
154
+ * content/views/*.json. Best-effort and READ-ONLY on the Mongo side: any
155
+ * failure (no mongodb package, no connection, empty collection) is a silent
156
+ * no-op, and existing view files always win.
157
+ *
158
+ * @param {object} [opts]
159
+ * @param {function} [opts.getDbImpl] - Injectable getDb for tests
160
+ * @returns {Promise<void>}
161
+ */
162
+ export async function migrateFromMongo({ getDbImpl } = {}) {
163
+ // Any view file present = already migrated / already in use.
164
+ try {
165
+ const entries = await fs.readdir(VIEWS_DIR);
166
+ if (entries.some(e => e.endsWith('.json'))) return;
167
+ } catch { /* dir absent — proceed */ }
168
+
169
+ let db;
170
+ try {
171
+ const getDb = getDbImpl ?? (await import('./connectionManager.js')).getDb;
172
+ db = getDb('default');
173
+ } catch {
174
+ return; // file-mode install — nothing to migrate
175
+ }
176
+
177
+ let docs;
178
+ try {
179
+ docs = await db.collection(LEGACY_VIEWS_COLLECTION)
180
+ .find({}, { projection: { _id: 0 } })
181
+ .toArray();
182
+ } catch {
183
+ return;
184
+ }
185
+ if (!docs.length) return;
186
+
187
+ let migrated = 0;
188
+ for (const doc of docs) {
189
+ const slug = slugify(String(doc.slug || ''));
190
+ if (!slug) continue;
191
+ try {
192
+ await writeView(slug, { ...doc, slug });
193
+ migrated++;
194
+ } catch { /* skip unwritable doc */ }
195
+ }
196
+ if (migrated) {
197
+ console.log(`[views] Migrated ${migrated} view config(s) from MongoDB to content/views/`);
198
+ }
199
+ }
200
+
92
201
  // ---------------------------------------------------------------------------
93
202
  // CRUD
94
203
  // ---------------------------------------------------------------------------
95
204
 
96
205
  /**
97
- * List all view configs.
206
+ * List all view configs, newest first.
98
207
  *
99
208
  * @returns {Promise<object[]>}
100
- * @throws {Error} If MongoDB is not available
101
209
  */
102
210
  export async function listViews() {
103
- const db = await getMetaDb();
104
- return db.collection(VIEWS_COLLECTION)
105
- .find({}, { projection: { _id: 0 } })
106
- .sort({ 'meta.createdAt': -1 })
107
- .toArray();
211
+ await ensureMigrated();
212
+
213
+ let entries;
214
+ try {
215
+ entries = await fs.readdir(VIEWS_DIR);
216
+ } catch {
217
+ return [];
218
+ }
219
+
220
+ const views = [];
221
+ for (const entry of entries.filter(e => e.endsWith('.json'))) {
222
+ try {
223
+ views.push(JSON.parse(await fs.readFile(path.join(VIEWS_DIR, entry), 'utf8')));
224
+ } catch {
225
+ // skip malformed
226
+ }
227
+ }
228
+ views.sort((a, b) => String(b.meta?.createdAt || '').localeCompare(String(a.meta?.createdAt || '')));
229
+ return views;
108
230
  }
109
231
 
110
232
  /**
@@ -112,12 +234,19 @@ export async function listViews() {
112
234
  *
113
235
  * @param {string} slug
114
236
  * @returns {Promise<object|null>}
115
- * @throws {Error} If MongoDB is not available
116
237
  */
117
238
  export async function getView(slug) {
118
- const db = await getMetaDb();
119
- const doc = await db.collection(VIEWS_COLLECTION).findOne({ slug }, { projection: { _id: 0 } });
120
- return doc || null;
239
+ await ensureMigrated();
240
+
241
+ // Strict slug guard — this is hit with raw URL params (public route) and
242
+ // the slug becomes a filename.
243
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug)) return null;
244
+
245
+ try {
246
+ return await readView(slug);
247
+ } catch {
248
+ return null;
249
+ }
121
250
  }
122
251
 
123
252
  /**
@@ -136,7 +265,6 @@ export async function getView(slug) {
136
265
  * @throws {Error} If slug already exists or validation fails
137
266
  */
138
267
  export async function createView(data, userId = null) {
139
- const db = await getMetaDb();
140
268
  const { title, description = '', connection = 'default', pipeline, display, access, meta: inputMeta } = data;
141
269
 
142
270
  if (!title) throw new Error('title is required');
@@ -145,8 +273,7 @@ export async function createView(data, userId = null) {
145
273
  const slug = data.slug ? slugify(data.slug) : slugify(title);
146
274
  if (!slug) throw new Error('Could not derive a slug from the title');
147
275
 
148
- const existing = await db.collection(VIEWS_COLLECTION).findOne({ slug });
149
- if (existing) throw new Error(`A view with slug "${slug}" already exists`);
276
+ if (await getView(slug)) throw new Error(`A view with slug "${slug}" already exists`);
150
277
 
151
278
  const stages = pipeline.stages || [];
152
279
  validateStages(stages);
@@ -178,8 +305,7 @@ export async function createView(data, userId = null) {
178
305
  }
179
306
  };
180
307
 
181
- await db.collection(VIEWS_COLLECTION).insertOne({ ...view });
182
- await cache.invalidateTags([`view:${slug}`]);
308
+ await writeView(slug, view);
183
309
  return view;
184
310
  }
185
311
 
@@ -192,13 +318,14 @@ export async function createView(data, userId = null) {
192
318
  * @throws {Error} If view not found or validation fails
193
319
  */
194
320
  export async function updateView(slug, data) {
195
- const db = await getMetaDb();
196
- const existing = await db.collection(VIEWS_COLLECTION).findOne({ slug });
321
+ const existing = await getView(slug);
197
322
  if (!existing) throw new Error(`View "${slug}" not found`);
198
323
 
199
324
  const stages = data.pipeline?.stages ?? existing.pipeline?.stages ?? [];
200
325
  validateStages(stages);
201
326
 
327
+ // `_id` strip is load-bearing for configs migrated from the legacy
328
+ // MongoDB store; `id` is immutable.
202
329
  const { _id, id, meta: inputMeta, ...rest } = data;
203
330
  const updated = {
204
331
  ...existing,
@@ -218,9 +345,8 @@ export async function updateView(slug, data) {
218
345
  }
219
346
  };
220
347
 
221
- await db.collection(VIEWS_COLLECTION).replaceOne({ slug }, { ...updated });
222
- await cache.invalidateTags([`view:${slug}`]);
223
348
  const { _id: _stripped, ...result } = updated;
349
+ await writeView(slug, result);
224
350
  return result;
225
351
  }
226
352
 
@@ -232,9 +358,14 @@ export async function updateView(slug, data) {
232
358
  * @throws {Error} If view not found
233
359
  */
234
360
  export async function deleteView(slug) {
235
- const db = await getMetaDb();
236
- const result = await db.collection(VIEWS_COLLECTION).deleteOne({ slug });
237
- if (result.deletedCount === 0) throw new Error(`View "${slug}" not found`);
361
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug)) {
362
+ throw new Error(`View "${slug}" not found`);
363
+ }
364
+ try {
365
+ await fs.unlink(path.join(VIEWS_DIR, `${slug}.json`));
366
+ } catch {
367
+ throw new Error(`View "${slug}" not found`);
368
+ }
238
369
  await cache.invalidateTags([`view:${slug}`]);
239
370
  }
240
371
 
@@ -243,28 +374,15 @@ export async function deleteView(slug) {
243
374
  // ---------------------------------------------------------------------------
244
375
 
245
376
  /**
246
- * Execute a view's aggregation pipeline and return paginated results.
247
- *
248
- * @param {string} slug
249
- * @param {object} [opts]
250
- * @param {number} [opts.page=1]
251
- * @param {number} [opts.limit=25]
252
- * @param {object|null} [opts.user=null] - Authenticated user (for row-level filtering)
253
- * @returns {Promise<{ results: object[], total: number, page: number, limit: number }>}
254
- * @throws {Error} If view not found, connection unavailable, or stage validation fails
377
+ * Execute a view against a MongoDB-backed source collection using native
378
+ * aggregation (unchanged pre-0.34 behaviour).
255
379
  */
256
- export async function executeView(slug, {page = 1, limit = 25, user = null} = {}) {
257
- const view = await getView(slug);
258
- if (!view) throw new Error(`View "${slug}" not found`);
259
-
380
+ async function executeMongoView(view, { page, limit, user }) {
260
381
  const { getDb } = await import('./connectionManager.js');
261
382
  const db = getDb(view.connection || 'default');
262
383
 
263
384
  const sourceCollection = `${COLLECTION_PREFIX}${view.pipeline.source}`;
264
- const stages = view.pipeline?.stages || [];
265
- validateStages(stages);
266
-
267
- const pipeline = buildPipelineStages(stages);
385
+ const pipeline = buildPipelineStages(view.pipeline?.stages || []);
268
386
 
269
387
  // Prepend row-level $match if configured
270
388
  const rowFilter = buildRowLevelMatch(user, view.access?.rowLevel);
@@ -293,3 +411,67 @@ export async function executeView(slug, {page = 1, limit = 25, user = null} = {}
293
411
 
294
412
  return { results, total, page, limit };
295
413
  }
414
+
415
+ /**
416
+ * Execute a view against any other source via the JS pipeline evaluator.
417
+ * The full collection and full pipeline output are materialised in memory —
418
+ * same trade the FileAdapter already makes for whole collections.
419
+ */
420
+ async function executeFileView(view, { page, limit, user }) {
421
+ const { getAdapter } = await import('./adapterRegistry.js');
422
+ const adapter = await getAdapter(view.pipeline.source);
423
+ const docs = await adapter.all(view.pipeline.source);
424
+
425
+ const stages = buildPipelineStages(view.pipeline?.stages || []);
426
+
427
+ const rowFilter = buildRowLevelMatch(user, view.access?.rowLevel);
428
+ if (rowFilter) {
429
+ stages.unshift({$match: rowFilter});
430
+ }
431
+
432
+ const all = await executePipeline(docs, stages, {
433
+ resolveCollection: async (slug) => (await getAdapter(slug)).all(slug),
434
+ });
435
+
436
+ const total = all.length;
437
+ const offset = (page - 1) * limit;
438
+ return { results: all.slice(offset, offset + limit), total, page, limit };
439
+ }
440
+
441
+ /**
442
+ * Execute a view's pipeline and return paginated results.
443
+ *
444
+ * @param {string} slug
445
+ * @param {object} [opts]
446
+ * @param {number} [opts.page=1]
447
+ * @param {number} [opts.limit=25]
448
+ * @param {object|null} [opts.user=null] - Authenticated user (for row-level filtering)
449
+ * @returns {Promise<{ results: object[], total: number, page: number, limit: number }>}
450
+ * @throws {Error} If view not found or stage validation fails
451
+ */
452
+ export async function executeView(slug, {page = 1, limit = 25, user = null} = {}) {
453
+ const view = await getView(slug);
454
+ if (!view) throw new Error(`View "${slug}" not found`);
455
+
456
+ validateStages(view.pipeline?.stages || []);
457
+
458
+ // Dispatch on the SOURCE collection's storage adapter. A mongodb-declared
459
+ // schema whose connection is unavailable falls back to the JS evaluator —
460
+ // matching adapterRegistry's own file fallback.
461
+ let useMongo = false;
462
+ try {
463
+ const { getCollection } = await import('./collections.js');
464
+ const schema = await getCollection(view.pipeline.source);
465
+ if (schema?.storage?.adapter === 'mongodb') {
466
+ const { getDb } = await import('./connectionManager.js');
467
+ getDb(view.connection || 'default'); // throws when unavailable
468
+ useMongo = true;
469
+ }
470
+ } catch {
471
+ useMongo = false;
472
+ }
473
+
474
+ return useMongo
475
+ ? executeMongoView(view, { page, limit, user })
476
+ : executeFileView(view, { page, limit, user });
477
+ }