@mongoosejs/studio 0.4.1 → 0.5.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.
@@ -2,9 +2,8 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const authorize = require('../../authorize');
5
- const MongooseStudioChartColors = require('../../constants/mongooseStudioChartColors');
5
+ const createSandbox = require('../../sandbox/createSandbox');
6
6
  const mongoose = require('mongoose');
7
- const vm = require('vm');
8
7
 
9
8
  const ExecuteScriptParams = new Archetype({
10
9
  initiatedById: {
@@ -17,13 +16,16 @@ const ExecuteScriptParams = new Archetype({
17
16
  script: {
18
17
  $type: 'string'
19
18
  },
19
+ dryRun: {
20
+ $type: 'boolean'
21
+ },
20
22
  roles: {
21
23
  $type: ['string']
22
24
  }
23
25
  }).compile('ExecuteScriptParams');
24
26
 
25
27
  module.exports = ({ db, studioConnection }) => async function executeScript(params) {
26
- const { initiatedById, chatMessageId, script, roles } = new ExecuteScriptParams(params);
28
+ const { initiatedById, chatMessageId, script, dryRun, roles } = new ExecuteScriptParams(params);
27
29
  const ChatThread = studioConnection.model('__Studio_ChatThread');
28
30
  const ChatMessage = studioConnection.model('__Studio_ChatMessage');
29
31
 
@@ -39,32 +41,19 @@ module.exports = ({ db, studioConnection }) => async function executeScript(para
39
41
  throw new Error('Unauthorized');
40
42
  }
41
43
 
42
- // Create a sandbox with the db object
43
- const logs = [];
44
- if (!db.Types) {
45
- db.Types = mongoose.Types;
46
- }
47
- const sandbox = { db, mongoose, console: {}, ObjectId: mongoose.Types.ObjectId, MongooseStudioChartColors };
48
-
49
- // Capture console logs
50
- sandbox.console.log = function() {
51
- const args = Array.from(arguments);
52
- logs.push(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg).join(' '));
53
- };
54
-
55
- const context = vm.createContext(sandbox);
44
+ const sandbox = createSandbox({ db });
56
45
 
57
46
  let output;
58
47
  let error;
59
48
 
60
49
  try {
61
50
  // Execute the script in the sandbox
62
- output = await vm.runInContext(wrappedScript(script), context);
51
+ output = await sandbox.runScript({ script, dryRun });
63
52
 
64
53
  const updatedContent = updateContentWithScript(chatMessage.content, chatMessage.script, script);
65
54
  chatMessage.script = script;
66
55
  chatMessage.content = updatedContent;
67
- chatMessage.executionResult = { output, logs: logs.join('\n'), error: null };
56
+ chatMessage.executionResult = { output, logs: sandbox.getLogs(), error: null, dryRun: !!dryRun };
68
57
  await chatMessage.save();
69
58
 
70
59
  return { chatMessage };
@@ -80,20 +69,23 @@ module.exports = ({ db, studioConnection }) => async function executeScript(para
80
69
  content: updatedContent,
81
70
  executionResult: {
82
71
  output: null,
83
- logs: logs.join('\n'),
84
- error
72
+ logs: sandbox.getLogs(),
73
+ error,
74
+ dryRun: !!dryRun
85
75
  }
86
76
  }
87
77
  );
88
78
 
89
79
  throw new Error(`Script execution failed: ${error}`);
80
+ } finally {
81
+ try {
82
+ await sandbox.close();
83
+ } catch (_) {
84
+ // Ignore sandbox cleanup errors so they do not mask the primary result.
85
+ }
90
86
  }
91
87
  };
92
88
 
93
- const wrappedScript = script => `(async () => {
94
- ${script}
95
- })()`;
96
-
97
89
  function updateContentWithScript(content, previousScript, newScript) {
98
90
  if (typeof content !== 'string') {
99
91
  return content;
@@ -62,9 +62,7 @@ module.exports = ({ db }) => async function exportQueryResults(params, req, res)
62
62
  }
63
63
  const csv = stringify(rows);
64
64
 
65
- res.set({
66
- 'Content-Type': 'text/csv',
67
- 'Content-Disposition': `attachment; filename="${model.toLowerCase()}-export.csv"`
68
- });
65
+ res.setHeader('Content-Type', 'text/csv');
66
+ res.setHeader('Content-Disposition', `attachment; filename="${model.toLowerCase()}-export.csv"`);
69
67
  return csv;
70
68
  };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ module.exports = ({ db, options }) => async function getCapabilities() {
4
+ const connection = db?.connection ?? db;
5
+ const admin = typeof connection?.db?.admin === 'function' ? connection.db.admin() : null;
6
+ const supportsAI = hasAIProviderKey(options);
7
+
8
+ if (admin == null || typeof admin.command !== 'function') {
9
+ return {
10
+ supportsChangeStreams: false,
11
+ supportsTransactions: false,
12
+ supportsAI
13
+ };
14
+ }
15
+
16
+ let hello;
17
+ try {
18
+ hello = await admin.command({ hello: 1 });
19
+ } catch (err) {
20
+ hello = await admin.command({ isMaster: 1 });
21
+ }
22
+
23
+ const maxWireVersion = hello?.maxWireVersion ?? 0;
24
+ const supportsModernMongoFeatures = maxWireVersion >= 7;
25
+ const isReplicaSet = !!hello?.setName;
26
+ const isMongos = hello?.msg === 'isdbgrid';
27
+ const hasSessions = hello?.logicalSessionTimeoutMinutes != null;
28
+
29
+ return {
30
+ supportsChangeStreams: supportsModernMongoFeatures && (isReplicaSet || isMongos),
31
+ supportsTransactions: supportsModernMongoFeatures && hasSessions && (isReplicaSet || isMongos),
32
+ supportsAI
33
+ };
34
+ };
35
+
36
+ function hasAIProviderKey(options) {
37
+ return [
38
+ options?.openAIAPIKey,
39
+ options?.anthropicAPIKey,
40
+ options?.googleGeminiAPIKey
41
+ ].some(value => typeof value === 'string' ? value.trim().length > 0 : !!value);
42
+ }
@@ -3,7 +3,7 @@
3
3
  exports.ChatMessage = require('./ChatMessage');
4
4
  exports.ChatThread = require('./ChatThread');
5
5
  exports.Dashboard = require('./Dashboard');
6
+ exports.getCapabilities = require('./getCapabilities');
6
7
  exports.Model = require('./Model');
7
- exports.Script = require('./Script');
8
8
  exports.status = require('./status');
9
9
  exports.Task = require('./Task');
@@ -28,7 +28,8 @@ const chatMessageSchema = new mongoose.Schema({
28
28
  executionResult: {
29
29
  output: mongoose.Schema.Types.Mixed,
30
30
  logs: String,
31
- error: String
31
+ error: String,
32
+ dryRun: Boolean
32
33
  }
33
34
  }, { timestamps: true });
34
35
 
package/backend/next.js CHANGED
@@ -81,8 +81,12 @@ module.exports = function next(conn, options) {
81
81
  throw new Error(`Action ${actionName} not found`);
82
82
  }
83
83
 
84
- return actionFn(params)
84
+ return actionFn(params, req, res)
85
85
  .then(result => {
86
+ if (typeof result === 'string') {
87
+ res.status(200).send(result);
88
+ return result;
89
+ }
86
90
  res.status(200).json(result);
87
91
  return result;
88
92
  })
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const MongooseStudioChartColors = require('../constants/mongooseStudioChartColors');
4
+ const mongoose = require('mongoose');
5
+ const vm = require('vm');
6
+ const { createScriptDb } = require('./createScriptDb');
7
+
8
+ module.exports = function createSandbox({ db }) {
9
+ const logs = [];
10
+ const scriptDb = createScriptDb(db);
11
+ if (!scriptDb.db.Types) {
12
+ scriptDb.db.Types = mongoose.Types;
13
+ }
14
+
15
+ const sandbox = {
16
+ db: scriptDb.db,
17
+ mongoose,
18
+ console: {},
19
+ ObjectId: mongoose.Types.ObjectId,
20
+ MongooseStudioChartColors
21
+ };
22
+
23
+ sandbox.console.log = function() {
24
+ const args = Array.from(arguments);
25
+ logs.push(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg).join(' '));
26
+ };
27
+
28
+ return {
29
+ context: vm.createContext(sandbox),
30
+ db: scriptDb.db,
31
+ getLogs() {
32
+ return logs.join('\n');
33
+ },
34
+ setDryRunSession: scriptDb.setDryRunSession,
35
+ async runScript({ script, dryRun }) {
36
+ const wrappedScript = `(async () => {
37
+ ${script}
38
+ })()`;
39
+
40
+ if (!dryRun) {
41
+ return await vm.runInContext(wrappedScript, this.context);
42
+ }
43
+
44
+ return await runDryRunScript({
45
+ db: this.db,
46
+ context: this.context,
47
+ setDryRunSession: this.setDryRunSession,
48
+ wrappedScript
49
+ });
50
+ },
51
+ close: scriptDb.close
52
+ };
53
+ };
54
+
55
+ async function runDryRunScript({ db, context, setDryRunSession, wrappedScript }) {
56
+ if (typeof db?.startSession !== 'function') {
57
+ throw new Error('Dry run mode requires MongoDB sessions support');
58
+ }
59
+
60
+ const session = await db.startSession();
61
+ setDryRunSession(session);
62
+
63
+ try {
64
+ session.startTransaction();
65
+ const result = await vm.runInContext(wrappedScript, context);
66
+ await abortTransaction(session);
67
+ return result;
68
+ } catch (err) {
69
+ await abortTransaction(session);
70
+ throw err;
71
+ } finally {
72
+ setDryRunSession(null);
73
+ await session.endSession().catch(() => {});
74
+ }
75
+ }
76
+
77
+ async function abortTransaction(session) {
78
+ if (typeof session?.inTransaction === 'function' && !session.inTransaction()) {
79
+ return;
80
+ }
81
+
82
+ await session.abortTransaction().catch(() => {});
83
+ }
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const wrappedCollections = new WeakSet();
4
+
5
+ const collectionMethodOptionsIndex = new Map([
6
+ ['bulkWrite', 1],
7
+ ['countDocuments', 1],
8
+ ['createIndex', 1],
9
+ ['createIndexes', 1],
10
+ ['deleteMany', 1],
11
+ ['deleteOne', 1],
12
+ ['distinct', 2],
13
+ ['drop', 0],
14
+ ['dropIndex', 1],
15
+ ['dropIndexes', 0],
16
+ ['estimatedDocumentCount', 0],
17
+ ['find', 1],
18
+ ['findOne', 1],
19
+ ['findOneAndDelete', 1],
20
+ ['findOneAndReplace', 2],
21
+ ['findOneAndUpdate', 2],
22
+ ['insertMany', 1],
23
+ ['insertOne', 1],
24
+ ['rename', 1],
25
+ ['replaceOne', 2],
26
+ ['updateMany', 2],
27
+ ['updateOne', 2]
28
+ ]);
29
+
30
+ function createScriptDb(db) {
31
+ const sourceConnection = db;
32
+
33
+ const scriptConnection = sourceConnection.useDb(sourceConnection.name, { useCache: false });
34
+ scriptConnection.config = {
35
+ ...scriptConnection.config,
36
+ autoCreate: false,
37
+ autoIndex: false
38
+ };
39
+
40
+ let dryRunSession = null;
41
+ const getSession = () => dryRunSession;
42
+ cloneModels(sourceConnection, scriptConnection, getSession);
43
+ wrapCollectionAccessors(scriptConnection, getSession);
44
+
45
+ const originalDebug = sourceConnection.options?.debug;
46
+ scriptConnection.set('debug', function() {
47
+ if (typeof originalDebug === 'function') {
48
+ return originalDebug.apply(this, arguments);
49
+ }
50
+ });
51
+
52
+ return {
53
+ db: scriptConnection,
54
+ setDryRunSession(session) {
55
+ dryRunSession = session;
56
+ },
57
+ close() {
58
+ if (Array.isArray(sourceConnection.otherDbs)) {
59
+ sourceConnection.otherDbs = sourceConnection.otherDbs.filter(db => db !== scriptConnection);
60
+ }
61
+ if (Array.isArray(scriptConnection.otherDbs)) {
62
+ scriptConnection.otherDbs = [];
63
+ }
64
+ if (sourceConnection.relatedDbs?.[scriptConnection.name] === scriptConnection) {
65
+ delete sourceConnection.relatedDbs[scriptConnection.name];
66
+ }
67
+ }
68
+ };
69
+ }
70
+
71
+ function cloneModels(sourceConnection, scriptConnection, getSession) {
72
+ for (const Model of Object.values(sourceConnection.models ?? {})) {
73
+ if (Model?.schema == null || typeof scriptConnection.model !== 'function') {
74
+ continue;
75
+ }
76
+
77
+ const ClonedModel = scriptConnection.model(Model.modelName, Model.schema, getCollectionName(Model));
78
+ wrapModelCollections(ClonedModel, getSession);
79
+ }
80
+ }
81
+
82
+ function getCollectionName(Model) {
83
+ return Model.collection?.collectionName ?? Model.collection?.name ?? Model.$__collection?.collectionName;
84
+ }
85
+
86
+ function wrapModelCollections(Model, getSession) {
87
+ const collection = Model?.collection ?? Model?.$__collection;
88
+ if (collection == null) {
89
+ return;
90
+ }
91
+
92
+ const wrappedCollection = wrapCollection(collection, getSession);
93
+ Model.collection = wrappedCollection;
94
+ Model.$__collection = wrappedCollection;
95
+
96
+ if (Model.prototype != null) {
97
+ Model.prototype.collection = wrappedCollection;
98
+ Model.prototype.$collection = wrappedCollection;
99
+ setModelCollectionSymbols(Model.prototype, wrappedCollection);
100
+ }
101
+ }
102
+
103
+ function setModelCollectionSymbols(target, collection) {
104
+ for (const symbol of Object.getOwnPropertySymbols(target)) {
105
+ if (symbol.description === 'mongoose#Model#collection') {
106
+ target[symbol] = collection;
107
+ }
108
+ }
109
+ }
110
+
111
+ function wrapCollectionAccessors(scriptConnection, getSession) {
112
+ const wrappedCollectionCache = new WeakMap();
113
+
114
+ const nativeDb = scriptConnection.db;
115
+ const originalDbCollection = nativeDb != null && typeof nativeDb.collection === 'function'
116
+ ? nativeDb.collection
117
+ : null;
118
+
119
+ if (typeof scriptConnection.collection === 'function') {
120
+ const originalConnectionCollection = scriptConnection.collection;
121
+ scriptConnection.collection = function() {
122
+ const collection = originalConnectionCollection.apply(this, arguments);
123
+ if (collection != null && originalDbCollection != null && collection.name != null) {
124
+ if (collection.collection == null || wrappedCollections.has(collection.collection)) {
125
+ collection.collection = originalDbCollection.call(nativeDb, collection.name);
126
+ }
127
+ }
128
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession);
129
+ };
130
+ }
131
+
132
+ if (originalDbCollection != null) {
133
+ nativeDb.collection = function() {
134
+ const collection = originalDbCollection.apply(this, arguments);
135
+ return getOrCreateWrappedCollection(wrappedCollectionCache, collection, getSession);
136
+ };
137
+ }
138
+ }
139
+
140
+ function getOrCreateWrappedCollection(cache, collection, getSession) {
141
+ if (collection == null || (typeof collection !== 'object' && typeof collection !== 'function')) {
142
+ return collection;
143
+ }
144
+ if (wrappedCollections.has(collection)) {
145
+ return collection;
146
+ }
147
+ if (cache.has(collection)) {
148
+ return cache.get(collection);
149
+ }
150
+ const wrapped = wrapCollection(collection, getSession);
151
+ cache.set(collection, wrapped);
152
+ return wrapped;
153
+ }
154
+
155
+ function wrapCollection(collection, getSession) {
156
+ if (wrappedCollections.has(collection)) {
157
+ return collection;
158
+ }
159
+ const wrapped = Object.create(collection);
160
+ wrappedCollections.add(wrapped);
161
+ for (const [methodName, optionsIndex] of collectionMethodOptionsIndex) {
162
+ const method = collection[methodName];
163
+ if (typeof method !== 'function') {
164
+ continue;
165
+ }
166
+
167
+ wrapped[methodName] = function() {
168
+ const args = addSessionOption(Array.from(arguments), optionsIndex, getSession());
169
+ return method.apply(collection, args);
170
+ };
171
+ }
172
+ return wrapped;
173
+ }
174
+
175
+ function addSessionOption(args, optionsIndex, session) {
176
+ if (session == null) {
177
+ return args;
178
+ }
179
+
180
+ while (args.length < optionsIndex) {
181
+ args.push(undefined);
182
+ }
183
+
184
+ const options = args[optionsIndex];
185
+
186
+ if (options != null && typeof options !== 'object') {
187
+ throw new Error('Cannot run dry run on script where options arg is a non-object');
188
+ }
189
+
190
+ if (options?.session != null) {
191
+ throw new Error('Cannot run dry run on script that uses sessions');
192
+ }
193
+
194
+ args[optionsIndex] = { ...(options ?? {}), session };
195
+ return args;
196
+ }
197
+
198
+ module.exports = {
199
+ createScriptDb,
200
+ collectionMethodOptionsIndex
201
+ };