domma-cms 0.33.1 → 0.34.1
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/admin/js/templates/views-list.html +0 -7
- package/admin/js/views/views-list.js +1 -1
- package/package.json +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/services/adapters/MongoAdapter.js +21 -0
- package/server/services/collections.js +13 -3
- package/server/services/markdown.js +2 -2
- package/server/services/viewPipeline.js +609 -0
- package/server/services/views.js +244 -62
|
@@ -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
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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
|
@@ -198,6 +198,27 @@ export class MongoAdapter {
|
|
|
198
198
|
await col.deleteMany({});
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Drop the backing MongoDB collection entirely (documents, indexes and
|
|
203
|
+
* the namespace). Used when the CMS collection itself is deleted —
|
|
204
|
+
* clear() only removes documents and would leave `cms_<slug>` behind.
|
|
205
|
+
*
|
|
206
|
+
* Deliberately bypasses _col(): that helper ensures the unique index,
|
|
207
|
+
* which would recreate the very collection being removed.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} slug
|
|
210
|
+
* @returns {Promise<void>}
|
|
211
|
+
*/
|
|
212
|
+
async drop(slug) {
|
|
213
|
+
try {
|
|
214
|
+
await this._db.collection(`${PREFIX}${slug}`).drop();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// Already absent — nothing to drop.
|
|
217
|
+
if (err.codeName !== 'NamespaceNotFound') throw err;
|
|
218
|
+
}
|
|
219
|
+
this._ensured.delete(slug);
|
|
220
|
+
}
|
|
221
|
+
|
|
201
222
|
/**
|
|
202
223
|
* Return all entries (used for export).
|
|
203
224
|
*
|
|
@@ -92,7 +92,10 @@ export async function listCollections() {
|
|
|
92
92
|
return results
|
|
93
93
|
.filter(r => r.status === 'fulfilled')
|
|
94
94
|
.map(r => r.value)
|
|
95
|
-
|
|
95
|
+
// Hand-rolled schemas can lack `title` — fall back to slug rather
|
|
96
|
+
// than crashing the whole listing (seen in the wild: a schema with
|
|
97
|
+
// `name` instead of `title` 500'd /api/collections permanently).
|
|
98
|
+
.sort((a, b) => String(a.title || a.slug || '').localeCompare(String(b.title || b.slug || '')));
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -210,11 +213,18 @@ export async function deleteCollection(slug) {
|
|
|
210
213
|
const schema = await getCollection(slug);
|
|
211
214
|
if (!schema) throw new Error(`Collection "${slug}" not found`);
|
|
212
215
|
|
|
213
|
-
//
|
|
216
|
+
// Remove adapter-side data first (handles MongoDB collections on
|
|
217
|
+
// non-file adapters). Prefer drop() — clear() only deletes documents
|
|
218
|
+
// and leaves the backing Mongo collection + its unique index behind
|
|
219
|
+
// forever.
|
|
214
220
|
try {
|
|
215
221
|
const adapter = await getAdapter(slug);
|
|
216
222
|
if (adapter.constructor.name !== 'FileAdapter') {
|
|
217
|
-
|
|
223
|
+
if (typeof adapter.drop === 'function') {
|
|
224
|
+
await adapter.drop(slug);
|
|
225
|
+
} else {
|
|
226
|
+
await adapter.clear(slug);
|
|
227
|
+
}
|
|
218
228
|
}
|
|
219
229
|
} catch {
|
|
220
230
|
// Ignore errors — directory removal handles file-backed data.
|
|
@@ -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
|
|
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 (
|
|
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
|
+
}
|
package/server/services/views.js
CHANGED
|
@@ -1,27 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Views Service
|
|
2
|
+
* Views Service
|
|
3
3
|
*
|
|
4
|
-
* View configs are stored
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
20
|
-
const
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|