From 226ac164a235c9a63c411b04cdc973e7a5faae7d Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:13:13 -0400 Subject: [PATCH 1/4] aggregation builder v1 --- backend/actions/Model/aggregate.js | 50 ++++ backend/actions/Model/index.js | 1 + backend/authorize.js | 1 + .../aggregation-builder.css | 36 +++ .../aggregation-builder.html | 142 +++++++++ .../aggregation-builder.js | 271 ++++++++++++++++++ frontend/src/api.js | 6 + frontend/src/navbar/navbar.html | 20 ++ frontend/src/navbar/navbar.js | 3 + frontend/src/routes.js | 18 +- 10 files changed, 543 insertions(+), 5 deletions(-) create mode 100644 backend/actions/Model/aggregate.js create mode 100644 frontend/src/aggregation-builder/aggregation-builder.css create mode 100644 frontend/src/aggregation-builder/aggregation-builder.html create mode 100644 frontend/src/aggregation-builder/aggregation-builder.js diff --git a/backend/actions/Model/aggregate.js b/backend/actions/Model/aggregate.js new file mode 100644 index 00000000..e9b0d98b --- /dev/null +++ b/backend/actions/Model/aggregate.js @@ -0,0 +1,50 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); + +const AggregateParams = new Archetype({ + model: { + $type: 'string', + $required: true + }, + pipeline: { + $type: Archetype.Any, + $required: true + }, + limit: { + $type: 'number', + $default: 20 + }, + roles: { + $type: ['string'] + } +}).compile('AggregateParams'); + +module.exports = ({ db }) => async function aggregate(params) { + params = new AggregateParams(params); + const { model, roles } = params; + await authorize('Model.aggregate', roles); + + const Model = db.models[model]; + if (Model == null) { + throw new Error(`Model ${model} not found`); + } + + if (!Array.isArray(params.pipeline)) { + throw new Error('`pipeline` must be an array'); + } + + const pipeline = params.pipeline.map((stage, index) => { + if (stage == null || Array.isArray(stage) || typeof stage !== 'object') { + throw new Error(`Invalid stage at index ${index}`); + } + return stage; + }); + + const limit = Math.max(1, Math.min(200, Math.floor(params.limit || 20))); + pipeline.push({ $limit: limit }); + + const docs = await Model.aggregate(pipeline).exec(); + return { docs }; +}; diff --git a/backend/actions/Model/index.js b/backend/actions/Model/index.js index aacf041c..aee9afc8 100644 --- a/backend/actions/Model/index.js +++ b/backend/actions/Model/index.js @@ -1,6 +1,7 @@ 'use strict'; exports.addField = require('./addField'); +exports.aggregate = require('./aggregate'); exports.createChatMessage = require('./createChatMessage'); exports.createDocument = require('./createDocument'); exports.deleteDocument = require('./deleteDocument'); diff --git a/backend/authorize.js b/backend/authorize.js index a4e0a9bd..a519e36d 100644 --- a/backend/authorize.js +++ b/backend/authorize.js @@ -17,6 +17,7 @@ const actionsToRequiredRoles = { 'Model.deleteDocument': ['owner', 'admin', 'member'], 'Model.deleteDocuments': ['owner', 'admin', 'member'], 'Model.dropIndex': ['owner', 'admin'], + 'Model.aggregate': ['owner', 'admin', 'member', 'readonly'], 'Model.executeDocumentScript': ['owner', 'admin', 'member'], 'Model.exportQueryResults': ['owner', 'admin', 'member', 'readonly'], 'Model.getDocument': ['owner', 'admin', 'member', 'readonly'], diff --git a/frontend/src/aggregation-builder/aggregation-builder.css b/frontend/src/aggregation-builder/aggregation-builder.css new file mode 100644 index 00000000..4771ed2e --- /dev/null +++ b/frontend/src/aggregation-builder/aggregation-builder.css @@ -0,0 +1,36 @@ +.aggregation-builder textarea { + tab-size: 2; +} + +.aggregation-builder-code-panel { + background: var(--surface); + color: var(--content); + border: 1px solid var(--edge); +} + +.aggregation-builder-code-panel code { + color: inherit; +} + +.aggregation-builder-doc-card { + border: 1px solid var(--edge); + border-radius: 0.375rem; + overflow: hidden; + background: var(--page); +} + +.aggregation-builder-doc-card-header { + padding: 0.4rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--content-tertiary); + border-bottom: 1px solid var(--edge); +} + +.aggregation-builder-doc-card-body { + margin: 0; + max-height: 260px; + overflow: auto; + border: 0; + border-radius: 0; +} diff --git a/frontend/src/aggregation-builder/aggregation-builder.html b/frontend/src/aggregation-builder/aggregation-builder.html new file mode 100644 index 00000000..fdd89846 --- /dev/null +++ b/frontend/src/aggregation-builder/aggregation-builder.html @@ -0,0 +1,142 @@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
Pipeline Stages
+
+
+
+ Stage {{index + 1}} + + +
+ +

{{getStageError(stage)}}

+
+ +
+

Loading preview...

+

{{stage.previewError}}

+

No documents returned.

+
+
+
Document {{docIndex + 1}}
+
{{formatDoc(doc)}}
+
+
+
+
+
+
+
+ +
+
Pipeline JSON
+
{{pipelinePreview}}
+
+
+ +
+
Results
+
+

{{errorMessage}}

+

Refreshing results...

+

Edit a stage to generate results automatically.

+
+

Showing {{results.length}} document(s).

+
+
+ +
{{formatDoc(doc)}}
+
+
+ +
+
+
+
+
diff --git a/frontend/src/aggregation-builder/aggregation-builder.js b/frontend/src/aggregation-builder/aggregation-builder.js new file mode 100644 index 00000000..3573abc4 --- /dev/null +++ b/frontend/src/aggregation-builder/aggregation-builder.js @@ -0,0 +1,271 @@ +'use strict'; + +const api = require('../api'); +const template = require('./aggregation-builder.html'); + +const appendCSS = require('../appendCSS'); +appendCSS(require('./aggregation-builder.css')); + +const STAGE_OPERATORS = [ + '$match', + '$project', + '$group', + '$sort', + '$limit', + '$skip', + '$unwind', + '$lookup', + '$addFields', + '$set', + '$unset', + '$count', + '$facet' +]; +const STAGE_PREVIEW_LIMIT = 3; +const AUTO_RUN_DEBOUNCE_MS = 450; +const RESULT_PAGE_SIZE = 20; + +function createDefaultStage() { + return { + id: Math.random().toString(36).slice(2), + operator: '$match', + bodyText: '{}', + previewDocs: [], + previewError: '', + previewLoading: false, + previewExpanded: false + }; +} + +module.exports = app => app.component('aggregation-builder', { + template: template, + props: ['roles'], + data: () => ({ + models: [], + selectedModel: null, + resultLimit: 20, + stages: [createDefaultStage()], + stageOperators: STAGE_OPERATORS, + isRunning: false, + errorMessage: '', + results: [], + visibleResultsCount: RESULT_PAGE_SIZE, + resultExpandedState: {}, + autoRunTimer: null, + previewRefreshTimer: null, + activeRunId: 0 + }), + computed: { + hasPipelineErrors() { + return this.stages.some(stage => this.getStageError(stage) != null); + }, + pipelineSignature() { + return this.stages.map(stage => `${stage.operator}::${stage.bodyText || ''}`).join('||'); + }, + pipelinePreview() { + return JSON.stringify(this.buildPipeline(), null, 2); + }, + visibleResults() { + return this.results.slice(0, this.visibleResultsCount); + }, + hasMoreResults() { + return this.visibleResultsCount < this.results.length; + } + }, + beforeUnmount() { + if (this.autoRunTimer != null) { + clearTimeout(this.autoRunTimer); + this.autoRunTimer = null; + } + if (this.previewRefreshTimer != null) { + clearTimeout(this.previewRefreshTimer); + this.previewRefreshTimer = null; + } + }, + async mounted() { + const { models } = await api.Model.listModels(); + this.models = models || []; + if (this.models.length > 0) { + this.selectedModel = this.models[0]; + this.scheduleAutoRun(); + this.scheduleAllStagePreviewsRefresh(); + } + }, + methods: { + addStage() { + this.stages.push(createDefaultStage()); + }, + removeStage(index) { + if (this.stages.length <= 1) { + return; + } + this.stages.splice(index, 1); + }, + getStageError(stage) { + const text = typeof stage.bodyText === 'string' ? stage.bodyText.trim() : ''; + if (!text) { + return null; + } + let parsedBody = null; + try { + parsedBody = JSON.parse(text); + } catch (err) { + return err.message; + } + if (parsedBody == null || Array.isArray(parsedBody) || typeof parsedBody !== 'object') { + return 'Stage body must be a JSON object'; + } + return null; + }, + buildPipeline() { + return this.stages.map(stage => { + const text = typeof stage.bodyText === 'string' ? stage.bodyText.trim() : ''; + let parsedBody = {}; + if (text) { + try { + parsedBody = JSON.parse(text); + } catch (err) { + parsedBody = {}; + } + } + return { [stage.operator]: parsedBody }; + }); + }, + formatDoc(doc) { + return JSON.stringify(doc, null, 2); + }, + toggleResult(index) { + this.resultExpandedState[index] = !this.resultExpandedState[index]; + }, + isResultExpanded(index) { + return !!this.resultExpandedState[index]; + }, + loadMoreResults() { + this.visibleResultsCount = Math.min(this.visibleResultsCount + RESULT_PAGE_SIZE, this.results.length); + }, + toggleStagePreview(stage) { + stage.previewExpanded = !stage.previewExpanded; + if (stage.previewExpanded) { + const stageIndex = this.stages.findIndex(s => s.id === stage.id); + this.loadSingleStagePreview(stageIndex); + } + }, + /** + * Keep every stage’s sample in sync (including while collapsed) so the header + * count and expand content match the current pipeline. + */ + scheduleAllStagePreviewsRefresh() { + if (this.previewRefreshTimer != null) { + clearTimeout(this.previewRefreshTimer); + } + this.previewRefreshTimer = setTimeout(() => { + this.refreshAllStagePreviews(); + this.previewRefreshTimer = null; + }, AUTO_RUN_DEBOUNCE_MS); + }, + refreshAllStagePreviews() { + if (!this.selectedModel || this.stages.length === 0) { + return; + } + for (let i = 0; i < this.stages.length; i++) { + this.loadSingleStagePreview(i); + } + }, + async loadSingleStagePreview(index) { + if (index < 0 || index >= this.stages.length) { + return; + } + const stage = this.stages[index]; + if (!this.selectedModel) { + return; + } + const token = (stage._previewRequestId = (stage._previewRequestId || 0) + 1); + stage.previewLoading = true; + stage.previewError = ''; + try { + const partialPipeline = this.buildPipeline().slice(0, index + 1); + const { docs } = await api.Model.aggregate({ + model: this.selectedModel, + pipeline: partialPipeline, + limit: STAGE_PREVIEW_LIMIT, + roles: this.roles + }); + if (token !== stage._previewRequestId) { + return; + } + stage.previewDocs = docs || []; + } catch (err) { + if (token !== stage._previewRequestId) { + return; + } + stage.previewError = err?.response?.data?.message || err.message || 'Could not preview this stage'; + stage.previewDocs = []; + } finally { + if (token === stage._previewRequestId) { + stage.previewLoading = false; + } + } + }, + scheduleAutoRun() { + if (this.autoRunTimer != null) { + clearTimeout(this.autoRunTimer); + } + this.autoRunTimer = setTimeout(() => { + this.runAggregation({ isAutoRun: true }); + }, AUTO_RUN_DEBOUNCE_MS); + }, + async runAggregation(options = {}) { + const isAutoRun = !!options.isAutoRun; + this.errorMessage = ''; + this.results = []; + this.resultExpandedState = {}; + this.visibleResultsCount = RESULT_PAGE_SIZE; + const pipeline = this.buildPipeline(); + if (this.hasPipelineErrors) { + if (!isAutoRun) { + this.errorMessage = 'Fix invalid stage JSON before running.'; + } + return; + } + if (!this.selectedModel) { + return; + } + const runId = ++this.activeRunId; + this.isRunning = true; + try { + const { docs } = await api.Model.aggregate({ + model: this.selectedModel, + pipeline, + limit: this.resultLimit, + roles: this.roles + }); + if (runId !== this.activeRunId) { + return; + } + this.results = docs || []; + } catch (err) { + if (runId !== this.activeRunId) { + return; + } + this.errorMessage = err?.response?.data?.message || err.message || 'Aggregation failed'; + } finally { + if (runId === this.activeRunId) { + this.isRunning = false; + } + } + } + }, + watch: { + pipelineSignature() { + this.scheduleAutoRun(); + this.scheduleAllStagePreviewsRefresh(); + }, + selectedModel() { + this.scheduleAutoRun(); + this.scheduleAllStagePreviewsRefresh(); + }, + resultLimit() { + this.scheduleAutoRun(); + } + } +}); diff --git a/frontend/src/api.js b/frontend/src/api.js index 3dfb4478..2d404f09 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -176,6 +176,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { }, updateDocuments: function updateDocuments(params) { return client.post('', { action: 'Model.updateDocuments', ...params }).then(res => res.data); + }, + aggregate(params) { + return client.post('', { action: 'Model.aggregate', ...params }).then(res => res.data); } }; exports.Task = { @@ -519,6 +522,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { }, updateDocuments: function updateDocument(params) { return client.post('/Model/updateDocuments', params).then(res => res.data); + }, + aggregate(params) { + return client.post('/Model/aggregate', params).then(res => res.data); } }; exports.Task = { diff --git a/frontend/src/navbar/navbar.html b/frontend/src/navbar/navbar.html index 8be86af2..ec6d7c2b 100644 --- a/frontend/src/navbar/navbar.html +++ b/frontend/src/navbar/navbar.html @@ -41,6 +41,16 @@ + Aggregation + + Aggregation + + + Aggregation + + Aggregation + + app.component('navbar', { chatView() { return ['chat index', 'chat'].includes(this.$route.name); }, + aggregationBuilderView() { + return this.$route.name === 'aggregationBuilder'; + }, taskView() { return ['tasks', 'taskByName', 'taskSingle'].includes(this.$route.name); }, diff --git a/frontend/src/routes.js b/frontend/src/routes.js index 5b15f7f4..eddfbdb7 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -2,14 +2,14 @@ // Role-based access control configuration const roleAccess = { - owner: ['root', 'model', 'document', 'dashboards', 'dashboard', 'team', 'chat', 'tasks', 'taskByName', 'taskSingle'], - admin: ['root', 'model', 'document', 'dashboards', 'dashboard', 'team', 'chat', 'tasks', 'taskByName', 'taskSingle'], - member: ['root', 'model', 'document', 'dashboards', 'dashboard', 'chat', 'tasks', 'taskByName', 'taskSingle'], - readonly: ['root', 'model', 'document', 'chat'], + owner: ['root', 'model', 'document', 'dashboards', 'dashboard', 'team', 'chat', 'aggregationBuilder', 'tasks', 'taskByName', 'taskSingle'], + admin: ['root', 'model', 'document', 'dashboards', 'dashboard', 'team', 'chat', 'aggregationBuilder', 'tasks', 'taskByName', 'taskSingle'], + member: ['root', 'model', 'document', 'dashboards', 'dashboard', 'chat', 'aggregationBuilder', 'tasks', 'taskByName', 'taskSingle'], + readonly: ['root', 'model', 'document', 'chat', 'aggregationBuilder'], dashboards: ['dashboards', 'dashboard'] }; -const allowedRoutesForLocalDev = ['document', 'dashboards', 'dashboard', 'root', 'chat', 'model', 'tasks', 'taskByName', 'taskSingle']; +const allowedRoutesForLocalDev = ['document', 'dashboards', 'dashboard', 'root', 'chat', 'aggregationBuilder', 'model', 'tasks', 'taskByName', 'taskSingle']; // Helper function to check if a role has access to a route function hasAccess(roles, routeName) { @@ -107,6 +107,14 @@ module.exports = { meta: { authorized: true } + }, + { + path: '/aggregation-builder', + name: 'aggregationBuilder', + component: 'aggregation-builder', + meta: { + authorized: true + } } ], roleAccess, From 1ef3b28bc3fc1d17416207bea879df6ee769c21c Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 11 May 2026 10:45:06 -0400 Subject: [PATCH 2/4] implement some feedback --- .../aggregation-builder.css | 36 --------------- .../aggregation-builder.html | 38 +++++++-------- .../aggregation-builder.js | 46 ++++--------------- 3 files changed, 25 insertions(+), 95 deletions(-) delete mode 100644 frontend/src/aggregation-builder/aggregation-builder.css diff --git a/frontend/src/aggregation-builder/aggregation-builder.css b/frontend/src/aggregation-builder/aggregation-builder.css deleted file mode 100644 index 4771ed2e..00000000 --- a/frontend/src/aggregation-builder/aggregation-builder.css +++ /dev/null @@ -1,36 +0,0 @@ -.aggregation-builder textarea { - tab-size: 2; -} - -.aggregation-builder-code-panel { - background: var(--surface); - color: var(--content); - border: 1px solid var(--edge); -} - -.aggregation-builder-code-panel code { - color: inherit; -} - -.aggregation-builder-doc-card { - border: 1px solid var(--edge); - border-radius: 0.375rem; - overflow: hidden; - background: var(--page); -} - -.aggregation-builder-doc-card-header { - padding: 0.4rem 0.6rem; - font-size: 0.75rem; - font-weight: 600; - color: var(--content-tertiary); - border-bottom: 1px solid var(--edge); -} - -.aggregation-builder-doc-card-body { - margin: 0; - max-height: 260px; - overflow: auto; - border: 0; - border-radius: 0; -} diff --git a/frontend/src/aggregation-builder/aggregation-builder.html b/frontend/src/aggregation-builder/aggregation-builder.html index fdd89846..2775cbfc 100644 --- a/frontend/src/aggregation-builder/aggregation-builder.html +++ b/frontend/src/aggregation-builder/aggregation-builder.html @@ -2,7 +2,7 @@
- + - - -
-
- - -
-
- - +
+
+
+
+
+
+

Aggregation options

+

Model, how many documents to return, and pipeline actions.

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
-
-
-
-
Pipeline Stages
-
+
+
+

Pipeline stages

+
+
+ +
+
+ Stage {{index + 1}} + +
-
- Stage {{index + 1}} + class="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,0.95fr)_minmax(0,1.1fr)] lg:grid-rows-1 lg:items-stretch lg:gap-5"> +
+
+ Operator -
+ -

{{getStageError(stage)}}

-
- -
-

Loading preview...

-

{{stage.previewError}}

-

No documents returned.

-
-
-
Document {{docIndex + 1}}
-
{{formatDoc(doc)}}
+
+ +
+
+ Pipeline JSON + · through stage {{index + 1}} +
+
{{ pipelinePreviewThrough(index) }}
+
+ +
+
Stage output preview
+
+
+ + +
+
+
+
+
-
- -
-
Pipeline JSON
-
{{pipelinePreview}}
-
-
Results
-
-

{{errorMessage}}

-

Refreshing results...

-

Click Run Now to execute the aggregation and show results here.

-
-

+

+
+ Results + Running… +
+
+

{{errorMessage}}

+

+ Click Run Now to execute the aggregation and show results here. +

+

Fetching results…

+
+

Showing {{ visibleResults.length }} of {{ results.length }} document(s).

-
+
- Load {{Math.min(20, results.length - visibleResultsCount)}} more + class="shrink-0 rounded-md border border-edge bg-surface px-3 py-1.5 text-xs font-semibold text-content-secondary hover:bg-muted"> + Load {{ nextLoadMoreCount }} more
diff --git a/frontend/src/aggregation-builder/aggregation-builder.js b/frontend/src/aggregation-builder/aggregation-builder.js index fa468901..1b09e2ce 100644 --- a/frontend/src/aggregation-builder/aggregation-builder.js +++ b/frontend/src/aggregation-builder/aggregation-builder.js @@ -2,6 +2,41 @@ const api = require('../api'); const template = require('./aggregation-builder.html'); +const { BSON } = require('mongodb/lib/bson'); + +const ObjectId = new Proxy(BSON.ObjectId, { + apply(target, thisArg, argumentsList) { + return new target(...argumentsList); + } +}); + +/** + * Parse a stage body as JSON, or as a JavaScript object/function literal + * (e.g. unquoted keys, single quotes, trailing commas, ObjectId(), Date, RegExp). + */ +function parseStageBody(text) { + const trimmed = typeof text === 'string' ? text.trim() : ''; + if (!trimmed) { + return {}; + } + try { + return JSON.parse(trimmed); + } catch { + // Not strict JSON — try a JS expression in a controlled scope. + } + try { + const fn = new Function( + 'ObjectId', + 'Date', + 'Math', + 'RegExp', + `return (${trimmed});` + ); + return fn(ObjectId, Date, Math, RegExp); + } catch (err) { + throw new Error(err.message || String(err)); + } +} const STAGE_OPERATORS = [ '$match', @@ -19,7 +54,6 @@ const STAGE_OPERATORS = [ '$facet' ]; const STAGE_PREVIEW_LIMIT = 3; -const PREVIEW_DEBOUNCE_MS = 450; const RESULT_PAGE_SIZE = 20; function createDefaultStage() { @@ -30,7 +64,8 @@ function createDefaultStage() { previewDocs: [], previewError: '', previewLoading: false, - previewExpanded: false + previewExpanded: false, + previewLoaded: false }; } @@ -48,49 +83,62 @@ module.exports = app => app.component('aggregation-builder', { results: [], visibleResultsCount: RESULT_PAGE_SIZE, resultsRenderKey: 0, - previewRefreshTimer: null, activeRunId: 0 }), computed: { hasPipelineErrors() { return this.stages.some(stage => this.getStageError(stage) != null); }, + pipelineStageErrors() { + const errors = []; + for (let i = 0; i < this.stages.length; i++) { + const message = this.getStageError(this.stages[i]); + if (message) { + errors.push({ stageNumber: i + 1, message }); + } + } + return errors; + }, pipelineSignature() { return this.stages.map(stage => `${stage.operator}::${stage.bodyText || ''}`).join('||'); }, - pipelinePreview() { - return JSON.stringify(this.buildPipeline(), null, 2); - }, visibleResults() { return this.results.slice(0, this.visibleResultsCount); }, hasMoreResults() { return this.visibleResultsCount < this.results.length; }, + nextLoadMoreCount() { + return Math.min(RESULT_PAGE_SIZE, this.results.length - this.visibleResultsCount); + }, visibleResultsExpandedFields() { return this.visibleResults.map((_, i) => `root[${i}]`); } }, - beforeUnmount() { - if (this.previewRefreshTimer != null) { - clearTimeout(this.previewRefreshTimer); - this.previewRefreshTimer = null; - } - }, async mounted() { const { models } = await api.Model.listModels(); this.models = models || []; if (this.models.length > 0) { this.selectedModel = this.models[0]; - this.scheduleAllStagePreviewsRefresh(); } }, methods: { addStage() { this.stages.push(createDefaultStage()); + this.$nextTick(() => { + const rows = this.$refs.workflowStageRows; + const el = Array.isArray(rows) ? rows[rows.length - 1] : rows; + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + }); }, removeStage(index) { - if (this.stages.length <= 1) { + if (index < 0 || index >= this.stages.length) { + return; + } + if (this.stages.length === 1) { + this.stages.splice(0, 1, createDefaultStage()); return; } this.stages.splice(index, 1); @@ -102,12 +150,12 @@ module.exports = app => app.component('aggregation-builder', { } let parsedBody = null; try { - parsedBody = JSON.parse(text); + parsedBody = parseStageBody(text); } catch (err) { return err.message; } if (parsedBody == null || Array.isArray(parsedBody) || typeof parsedBody !== 'object') { - return 'Stage body must be a JSON object'; + return 'Stage body must be a plain object'; } return null; }, @@ -117,7 +165,7 @@ module.exports = app => app.component('aggregation-builder', { let parsedBody = {}; if (text) { try { - parsedBody = JSON.parse(text); + parsedBody = parseStageBody(text); } catch (err) { parsedBody = {}; } @@ -125,6 +173,10 @@ module.exports = app => app.component('aggregation-builder', { return { [stage.operator]: parsedBody }; }); }, + pipelinePreviewThrough(index) { + const slice = this.buildPipeline().slice(0, index + 1); + return JSON.stringify(slice, null, 2); + }, formatDoc(doc) { return JSON.stringify(doc, null, 2); }, @@ -133,31 +185,25 @@ module.exports = app => app.component('aggregation-builder', { }, toggleStagePreview(stage) { stage.previewExpanded = !stage.previewExpanded; - if (stage.previewExpanded) { - const stageIndex = this.stages.findIndex(s => s.id === stage.id); - this.loadSingleStagePreview(stageIndex); - } - }, - /** - * Keep every stage’s sample in sync (including while collapsed) so the header - * count and expand content match the current pipeline. - */ - scheduleAllStagePreviewsRefresh() { - if (this.previewRefreshTimer != null) { - clearTimeout(this.previewRefreshTimer); - } - this.previewRefreshTimer = setTimeout(() => { - this.refreshAllStagePreviews(); - this.previewRefreshTimer = null; - }, PREVIEW_DEBOUNCE_MS); - }, - refreshAllStagePreviews() { - if (!this.selectedModel || this.stages.length === 0) { + }, + pipelineThroughIndexHasErrors(index) { + for (let i = 0; i <= index; i++) { + if (this.getStageError(this.stages[i]) != null) { + return true; + } + } + return false; + }, + runStagePreview(index) { + if (index < 0 || index >= this.stages.length || !this.selectedModel) { return; } - for (let i = 0; i < this.stages.length; i++) { - this.loadSingleStagePreview(i); + if (this.pipelineThroughIndexHasErrors(index)) { + return; } + const stage = this.stages[index]; + stage.previewExpanded = true; + this.loadSingleStagePreview(index); }, async loadSingleStagePreview(index) { if (index < 0 || index >= this.stages.length) { @@ -182,25 +228,34 @@ module.exports = app => app.component('aggregation-builder', { return; } stage.previewDocs = docs || []; + stage.previewLoaded = true; } catch (err) { if (token !== stage._previewRequestId) { return; } stage.previewError = err?.response?.data?.message || err.message || 'Could not preview this stage'; stage.previewDocs = []; + stage.previewLoaded = true; } finally { if (token === stage._previewRequestId) { stage.previewLoading = false; } } }, + invalidateStagePreviews() { + for (const stage of this.stages) { + stage._previewRequestId = (stage._previewRequestId || 0) + 1; + stage.previewDocs = []; + stage.previewError = ''; + stage.previewLoading = false; + stage.previewLoaded = false; + } + }, async runAggregation() { this.errorMessage = ''; - this.results = []; - this.visibleResultsCount = RESULT_PAGE_SIZE; const pipeline = this.buildPipeline(); if (this.hasPipelineErrors) { - this.errorMessage = 'Fix invalid stage JSON before running.'; + this.errorMessage = 'Fix invalid stage syntax before running.'; return; } if (!this.selectedModel) { @@ -219,6 +274,7 @@ module.exports = app => app.component('aggregation-builder', { return; } this.results = docs || []; + this.visibleResultsCount = RESULT_PAGE_SIZE; this.resultsRenderKey += 1; } catch (err) { if (runId !== this.activeRunId) { @@ -234,10 +290,10 @@ module.exports = app => app.component('aggregation-builder', { }, watch: { pipelineSignature() { - this.scheduleAllStagePreviewsRefresh(); + this.invalidateStagePreviews(); }, selectedModel() { - this.scheduleAllStagePreviewsRefresh(); + this.invalidateStagePreviews(); } } }); diff --git a/test/Model.aggregate.test.js b/test/Model.aggregate.test.js new file mode 100644 index 00000000..4c92291a --- /dev/null +++ b/test/Model.aggregate.test.js @@ -0,0 +1,134 @@ +'use strict'; + +const assert = require('assert'); +const mongoose = require('mongoose'); +const { actions, connection } = require('./setup.test'); + +describe('Model.aggregate()', function () { + const AggregateTest = connection.model('AggregateTest', new mongoose.Schema({ + name: String, + n: Number + })); + + afterEach(async function () { + await AggregateTest.deleteMany(); + }); + + it('returns documents from a valid pipeline', async function () { + await AggregateTest.create([{ name: 'a', n: 1 }, { name: 'b', n: 2 }]); + + const res = await actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: { name: 'a' } }], + roles: ['readonly'] + }); + + assert.ok(res.docs); + assert.strictEqual(res.docs.length, 1); + assert.strictEqual(res.docs[0].name, 'a'); + }); + + it('appends a server-side $limit and does not return more than the limit', async function () { + await AggregateTest.create([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 4 }, { n: 5 }]); + + const res = await actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }, { $sort: { n: 1 } }], + limit: 2, + roles: ['member'] + }); + + assert.strictEqual(res.docs.length, 2); + assert.strictEqual(res.docs[0].n, 1); + assert.strictEqual(res.docs[1].n, 2); + }); + + it('defaults limit to 20 when omitted', async function () { + const docsToCreate = Array.from({ length: 25 }, (_, i) => ({ n: i })); + await AggregateTest.create(docsToCreate); + + const res = await actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }], + roles: ['admin'] + }); + + assert.strictEqual(res.docs.length, 20); + }); + + it('clamps limit to 200', async function () { + const docsToCreate = Array.from({ length: 205 }, (_, i) => ({ n: i })); + await AggregateTest.create(docsToCreate); + + const res = await actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }], + limit: 500, + roles: ['admin'] + }); + + assert.strictEqual(res.docs.length, 200); + }); + + it('clamps a non-positive limit to 1', async function () { + await AggregateTest.create([{ n: 1 }, { n: 2 }]); + + const res = await actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }, { $sort: { n: 1 } }], + limit: 0, + roles: ['admin'] + }); + + assert.strictEqual(res.docs.length, 1); + assert.strictEqual(res.docs[0].n, 1); + }); + + it('throws if the model does not exist', async function () { + await assert.rejects( + () => + actions.Model.aggregate({ + model: 'DoesNotExistModel', + pipeline: [{ $match: {} }], + roles: ['admin'] + }), + /Model DoesNotExistModel not found/ + ); + }); + + it('throws if pipeline is not an array', async function () { + await assert.rejects( + () => + actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: { $match: {} }, + roles: ['admin'] + }), + /`pipeline` must be an array/ + ); + }); + + it('throws if a stage is not a non-null object', async function () { + await assert.rejects( + () => + actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }, null], + roles: ['admin'] + }), + /Invalid stage at index 1/ + ); + }); + + it('throws if the caller is not authorized', async function () { + await assert.rejects( + () => + actions.Model.aggregate({ + model: 'AggregateTest', + pipeline: [{ $match: {} }], + roles: ['nope'] + }), + /Unauthorized to take action Model\.aggregate/ + ); + }); +}); From 6e31ec8cee55b3c8ce0c655e91c466b1d866661b Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 11 May 2026 14:27:05 -0400 Subject: [PATCH 4/4] handle non json text --- backend/actions/Model/aggregate.js | 14 +++++++- .../aggregation-builder.html | 10 ++++-- .../aggregation-builder.js | 32 +++++++++++++++---- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/actions/Model/aggregate.js b/backend/actions/Model/aggregate.js index 35d45a1d..c7b23b64 100644 --- a/backend/actions/Model/aggregate.js +++ b/backend/actions/Model/aggregate.js @@ -1,6 +1,7 @@ 'use strict'; const Archetype = require('archetype'); +const { EJSON } = require('mongoose').mongo.BSON; const authorize = require('../../authorize'); const AggregateParams = new Archetype({ @@ -35,7 +36,18 @@ module.exports = ({ db }) => async function aggregate(params) { throw new Error('`pipeline` must be an array'); } - const pipeline = params.pipeline.map((stage, index) => { + let pipeline; + try { + pipeline = EJSON.deserialize(params.pipeline); + } catch (err) { + throw new Error(`Invalid pipeline (EJSON): ${err.message}`); + } + + if (!Array.isArray(pipeline)) { + throw new Error('`pipeline` must be an array'); + } + + pipeline = pipeline.map((stage, index) => { if (stage == null || Array.isArray(stage) || typeof stage !== 'object') { throw new Error(`Invalid stage at index ${index}`); } diff --git a/frontend/src/aggregation-builder/aggregation-builder.html b/frontend/src/aggregation-builder/aggregation-builder.html index 5cf0e3f6..10d5402e 100644 --- a/frontend/src/aggregation-builder/aggregation-builder.html +++ b/frontend/src/aggregation-builder/aggregation-builder.html @@ -110,13 +110,19 @@

Pipeline stages

v-model="stage.bodyText" spellcheck="false" class="min-h-[11rem] w-full min-w-0 flex-1 resize-y rounded-md border border-edge bg-page px-3 py-2 font-mono text-sm text-content focus:border-edge-strong focus:outline-none [tab-size:2] md:min-h-[12rem]" - placeholder="{ status: 'active' }" + placeholder="{ _id: ObjectId('...'), createdAt: new Date('2024-01-01') }" > +

+ Same idea as the create document editor: JSON or JavaScript-style objects with + ObjectId(), + new Date(), + RegExp, etc. Types are sent with EJSON so they match on the server. +

- Pipeline JSON + Pipeline JSON (EJSON) · through stage {{index + 1}}
 app.component('aggregation-builder', {
     },
     pipelinePreviewThrough(index) {
       const slice = this.buildPipeline().slice(0, index + 1);
-      return JSON.stringify(slice, null, 2);
+      try {
+        return JSON.stringify(serializePipelineForWire(slice), null, 2);
+      } catch (err) {
+        return `/* Could not serialize pipeline for preview: ${err.message} */\n${JSON.stringify(slice, null, 2)}`;
+      }
     },
     formatDoc(doc) {
       return JSON.stringify(doc, null, 2);
@@ -218,9 +230,10 @@ module.exports = app => app.component('aggregation-builder', {
       stage.previewError = '';
       try {
         const partialPipeline = this.buildPipeline().slice(0, index + 1);
+        const wirePipeline = serializePipelineForWire(partialPipeline);
         const { docs } = await api.Model.aggregate({
           model: this.selectedModel,
-          pipeline: partialPipeline,
+          pipeline: wirePipeline,
           limit: STAGE_PREVIEW_LIMIT,
           roles: this.roles
         });
@@ -264,9 +277,16 @@ module.exports = app => app.component('aggregation-builder', {
       const runId = ++this.activeRunId;
       this.isRunning = true;
       try {
+        let wirePipeline;
+        try {
+          wirePipeline = serializePipelineForWire(pipeline);
+        } catch (err) {
+          this.errorMessage = `Could not serialize pipeline: ${err.message}`;
+          return;
+        }
         const { docs } = await api.Model.aggregate({
           model: this.selectedModel,
-          pipeline,
+          pipeline: wirePipeline,
           limit: this.resultLimit,
           roles: this.roles
         });