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
});