@mongoosejs/studio 0.2.9 → 0.2.10

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.
@@ -13,6 +13,7 @@ exports.getDocumentsStream = require('./getDocumentsStream');
13
13
  exports.getCollectionInfo = require('./getCollectionInfo');
14
14
  exports.getIndexes = require('./getIndexes');
15
15
  exports.listModels = require('./listModels');
16
+ exports.streamDocumentChanges = require('./streamDocumentChanges');
16
17
  exports.streamChatMessage = require('./streamChatMessage');
17
18
  exports.updateDocument = require('./updateDocument');
18
19
  exports.updateDocuments = require('./updateDocuments');
@@ -2,6 +2,8 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const authorize = require('../../authorize');
5
+ const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
6
+ const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
5
7
 
6
8
  const ListModelsParams = new Archetype({
7
9
  roles: {
@@ -15,8 +17,41 @@ module.exports = ({ db }) => async function listModels(params) {
15
17
 
16
18
  const readyState = db.connection?.readyState ?? db.readyState;
17
19
 
20
+ const models = Object.keys(db.models).filter(key => !key.startsWith('__Studio_')).sort();
21
+
22
+ const modelSchemaPaths = {};
23
+ for (const modelName of models) {
24
+ const Model = db.models[modelName];
25
+ const schemaPaths = {};
26
+ modelSchemaPaths[modelName] = schemaPaths;
27
+ for (const path of Object.keys(Model.schema.paths)) {
28
+ const schemaType = Model.schema.paths[path];
29
+ schemaPaths[path] = {
30
+ instance: schemaType.instance,
31
+ path,
32
+ ref: getRefFromSchemaType(schemaType),
33
+ required: schemaType.options?.required,
34
+ enum: schemaType.options?.enum
35
+ };
36
+ if (schemaType.schema) {
37
+ schemaPaths[path].schema = {};
38
+ for (const subpath of Object.keys(schemaType.schema.paths)) {
39
+ schemaPaths[path].schema[subpath] = {
40
+ instance: schemaType.schema.paths[subpath].instance,
41
+ path: subpath,
42
+ ref: getRefFromSchemaType(schemaType.schema.paths[subpath]),
43
+ required: schemaType.schema.paths[subpath].options?.required,
44
+ enum: schemaType.schema.paths[subpath].options?.enum
45
+ };
46
+ }
47
+ }
48
+ }
49
+ removeSpecifiedPaths(schemaPaths, '.$*');
50
+ }
51
+
18
52
  return {
19
- models: Object.keys(db.models).filter(key => !key.startsWith('__Studio_')).sort(),
53
+ models,
54
+ modelSchemaPaths,
20
55
  readyState
21
56
  };
22
57
  };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const Archetype = require('archetype');
4
+ const authorize = require('../../authorize');
5
+
6
+ const StreamDocumentChangesParams = new Archetype({
7
+ model: {
8
+ $type: 'string',
9
+ $required: true
10
+ },
11
+ documentId: {
12
+ $type: 'string',
13
+ $required: true
14
+ },
15
+ roles: {
16
+ $type: ['string']
17
+ }
18
+ }).compile('StreamDocumentChangesParams');
19
+
20
+ module.exports = ({ db, changeStream }) => async function* streamDocumentChanges(params) {
21
+ const { model, documentId, roles } = new StreamDocumentChangesParams(params);
22
+
23
+ await authorize('Model.streamDocumentChanges', roles);
24
+
25
+ const Model = db.models[model];
26
+ if (Model == null) {
27
+ throw new Error(`Model ${model} not found`);
28
+ }
29
+
30
+ if (!changeStream) {
31
+ throw new Error('Change streams are not enabled');
32
+ }
33
+
34
+ const collectionName = Model.collection.name;
35
+ const targetId = String(documentId);
36
+
37
+ const queue = [];
38
+ let resolveQueue = null;
39
+ let streamError = null;
40
+ let streamEnded = false;
41
+
42
+ function enqueue(payload) {
43
+ queue.push(payload);
44
+ if (resolveQueue) {
45
+ const resolve = resolveQueue;
46
+ resolveQueue = null;
47
+ resolve();
48
+ }
49
+ }
50
+
51
+ function handleChange(change) {
52
+ if (!change || change.ns?.coll !== collectionName) {
53
+ return;
54
+ }
55
+ if (!change.documentKey || change.documentKey._id == null) {
56
+ return;
57
+ }
58
+ if (String(change.documentKey._id) !== targetId) {
59
+ return;
60
+ }
61
+
62
+ enqueue({
63
+ type: 'change',
64
+ operationType: change.operationType,
65
+ documentKey: change.documentKey,
66
+ ns: change.ns,
67
+ updateDescription: change.updateDescription,
68
+ clusterTime: change.clusterTime
69
+ });
70
+ }
71
+
72
+ function handleError(err) {
73
+ streamError = err || new Error('Change stream error');
74
+ enqueue({ type: 'error', message: streamError.message });
75
+ }
76
+
77
+ function handleEnd() {
78
+ streamEnded = true;
79
+ enqueue({ type: 'end' });
80
+ }
81
+
82
+ changeStream.on('change', handleChange);
83
+ changeStream.on('error', handleError);
84
+ changeStream.on('end', handleEnd);
85
+
86
+ try {
87
+ while (true) {
88
+ if (streamError) {
89
+ throw streamError;
90
+ }
91
+
92
+ if (queue.length === 0) {
93
+ await new Promise(resolve => {
94
+ resolveQueue = resolve;
95
+ });
96
+ }
97
+
98
+ if (streamError) {
99
+ throw streamError;
100
+ }
101
+
102
+ while (queue.length > 0) {
103
+ const payload = queue.shift();
104
+ if (payload?.type === 'end') {
105
+ return;
106
+ }
107
+ yield payload;
108
+ }
109
+
110
+ if (streamEnded) {
111
+ return;
112
+ }
113
+ }
114
+ } finally {
115
+ changeStream.off('change', handleChange);
116
+ changeStream.off('error', handleError);
117
+ changeStream.off('end', handleEnd);
118
+ if (resolveQueue) {
119
+ resolveQueue();
120
+ resolveQueue = null;
121
+ }
122
+ }
123
+ };
@@ -23,6 +23,7 @@ const actionsToRequiredRoles = {
23
23
  'Model.getDocumentsStream': ['owner', 'admin', 'member', 'readonly'],
24
24
  'Model.getIndexes': ['owner', 'admin', 'member', 'readonly'],
25
25
  'Model.listModels': ['owner', 'admin', 'member', 'readonly'],
26
+ 'Model.streamDocumentChanges': ['owner', 'admin', 'member', 'readonly'],
26
27
  'Model.streamChatMessage': ['owner', 'admin', 'member', 'readonly'],
27
28
  'Model.updateDocuments': ['owner', 'admin', 'member']
28
29
  };
package/backend/index.js CHANGED
@@ -16,6 +16,12 @@ module.exports = function backend(db, studioConnection, options) {
16
16
  const ChatMessage = studioConnection.model('__Studio_ChatMessage', chatMessageSchema, 'studio__chatMessages');
17
17
  const ChatThread = studioConnection.model('__Studio_ChatThread', chatThreadSchema, 'studio__chatThreads');
18
18
 
19
- const actions = applySpec(Actions, { db, studioConnection, options });
19
+ let changeStream = null;
20
+ if (options?.changeStream) {
21
+ changeStream = db.watch();
22
+ }
23
+
24
+ const actions = applySpec(Actions, { db, studioConnection, options, changeStream });
25
+ actions.services = { changeStream };
20
26
  return actions;
21
27
  };
package/eslint.config.js CHANGED
@@ -39,7 +39,9 @@ module.exports = defineConfig([
39
39
  process: true,
40
40
  setTimeout: true,
41
41
  navigator: true,
42
- TextDecoder: true
42
+ TextDecoder: true,
43
+ AbortController: true,
44
+ clearTimeout: true
43
45
  },
44
46
  sourceType: 'commonjs'
45
47
  },
package/express.js CHANGED
@@ -7,8 +7,9 @@ const { toRoute, objectRouter } = require('extrovert');
7
7
 
8
8
  module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options) {
9
9
  const router = express.Router();
10
+ options = options ? { changeStream: true, ...options } : { changeStream: true };
10
11
 
11
- const mothershipUrl = options?._mothershipUrl || 'https://mongoose-js.netlify.app/.netlify/functions';
12
+ const mothershipUrl = options._mothershipUrl || 'https://mongoose-js.netlify.app/.netlify/functions';
12
13
  let workspace = null;
13
14
  if (options?.apiKey) {
14
15
  ({ workspace } = await fetch(`${mothershipUrl}/getWorkspace`, {
@@ -31,7 +32,7 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
31
32
  }
32
33
 
33
34
  apiUrl = apiUrl || 'api';
34
- const backend = Backend(conn, options?.studioConnection, options);
35
+ const backend = Backend(conn, options.studioConnection, options);
35
36
 
36
37
  router.use(
37
38
  '/api',
@@ -689,6 +689,16 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
689
689
  yield { document: doc };
690
690
  }
691
691
  },
692
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
693
+ const pollIntervalMs = 5000;
694
+ while (!options.signal?.aborted) {
695
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
696
+ if (options.signal?.aborted) {
697
+ return;
698
+ }
699
+ yield { type: 'poll', model: params.model, documentId: params.documentId };
700
+ }
701
+ },
692
702
  getCollectionInfo: function getCollectionInfo(params) {
693
703
  return client.post('', { action: 'Model.getCollectionInfo', ...params }).then(res => res.data);
694
704
  },
@@ -909,6 +919,56 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
909
919
  }
910
920
  }
911
921
  },
922
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
923
+ const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
924
+ const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/streamDocumentChanges?' + new URLSearchParams(params).toString();
925
+
926
+ const response = await fetch(url, {
927
+ method: 'GET',
928
+ headers: {
929
+ Authorization: `${accessToken}`,
930
+ Accept: 'text/event-stream'
931
+ },
932
+ signal: options.signal
933
+ });
934
+
935
+ if (!response.ok) {
936
+ throw new Error(`HTTP error! Status: ${response.status}`);
937
+ }
938
+
939
+ const reader = response.body.getReader();
940
+ const decoder = new TextDecoder('utf-8');
941
+ let buffer = '';
942
+
943
+ while (true) {
944
+ const { done, value } = await reader.read();
945
+ if (done) break;
946
+ buffer += decoder.decode(value, { stream: true });
947
+
948
+ let eventEnd;
949
+ while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
950
+ const eventStr = buffer.slice(0, eventEnd);
951
+ buffer = buffer.slice(eventEnd + 2);
952
+
953
+ // Parse SSE event
954
+ const lines = eventStr.split('\n');
955
+ let data = '';
956
+ for (const line of lines) {
957
+ if (line.startsWith('data:')) {
958
+ data += line.slice(5).trim();
959
+ }
960
+ }
961
+ if (data) {
962
+ try {
963
+ yield JSON.parse(data);
964
+ } catch (err) {
965
+ // If not JSON, yield as string
966
+ yield data;
967
+ }
968
+ }
969
+ }
970
+ }
971
+ },
912
972
  getCollectionInfo: function getCollectionInfo(params) {
913
973
  return client.post('/Model/getCollectionInfo', params).then(res => res.data);
914
974
  },
@@ -2126,7 +2186,7 @@ module.exports = app => app.component('dashboard-chart', {
2126
2186
  (module) {
2127
2187
 
2128
2188
  "use strict";
2129
- module.exports = "<div class=\"py-2\">\n <div v-if=\"header\" class=\"border-b border-gray-100 px-2 pb-2 text-xl font-bold\">\n {{header}}\n </div>\n <div class=\"text-xl pb-2\">\n <document-details :document=\"value.$document.document\" :schemaPaths=\"schemaPaths\" />\n </div>\n</div>";
2189
+ module.exports = "<div class=\"py-2\">\n <div class=\"text-xl pb-2\">\n <list-json\n :value=\"value.$document.document\"\n :references=\"references\" />\n </div>\n</div>\n";
2130
2190
 
2131
2191
  /***/ },
2132
2192
 
@@ -2146,23 +2206,22 @@ const template = __webpack_require__(/*! ./dashboard-document.html */ "./fronten
2146
2206
  module.exports = app => app.component('dashboard-document', {
2147
2207
  template: template,
2148
2208
  props: ['value'],
2209
+ inject: ['state'],
2149
2210
  computed: {
2150
- header() {
2151
- if (this.value != null && this.value.$document.header) {
2152
- return this.value.$document.header;
2211
+ references() {
2212
+ const model = this.value.$document?.model;
2213
+ if (typeof model === 'string' && this.state.modelSchemaPaths?.[model]) {
2214
+ const map = {};
2215
+ for (const path of Object.keys(this.state.modelSchemaPaths[model])) {
2216
+ const definition = this.state.modelSchemaPaths[model][path];
2217
+ if (definition?.ref) {
2218
+ map[path] = definition.ref;
2219
+ }
2220
+ }
2221
+ return map;
2153
2222
  }
2223
+
2154
2224
  return null;
2155
- },
2156
- schemaPaths() {
2157
- return Object.keys(this.value?.$document?.schemaPaths || {}).sort((k1, k2) => {
2158
- if (k1 === '_id' && k2 !== '_id') {
2159
- return -1;
2160
- }
2161
- if (k1 !== '_id' && k2 === '_id') {
2162
- return 1;
2163
- }
2164
- return 0;
2165
- }).map(key => this.value?.$document.schemaPaths[key]);
2166
2225
  }
2167
2226
  }
2168
2227
  });
@@ -4893,7 +4952,7 @@ module.exports = ".document .document-menu {\n display: flex;\n position: stic
4893
4952
  (module) {
4894
4953
 
4895
4954
  "use strict";
4896
- module.exports = "<div class=\"document px-1 pt-4 pb-16 md:px-0 bg-slate-50 w-full\">\n <div class=\"max-w-7xl mx-auto\">\n <div class=\"flex gap-4 items-center sticky top-0 z-50 bg-white p-4 border-b border-gray-200 shadow-sm\">\n <div class=\"font-bold overflow-hidden text-ellipsis\">{{model}}: {{documentId}}</div>\n <div class=\"flex grow\">\n <button\n @click=\"viewMode = 'fields'\"\n :class=\"viewMode === 'fields'\n ? 'bg-blue-600 text-white z-10'\n : 'bg-gray-200 text-gray-700 hover:bg-gray-300'\"\n class=\"px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-gray-300 border-r-0 rounded-l-lg rounded-r-none\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 10h16M4 14h16M4 18h16\"></path>\n </svg>\n Fields\n </button>\n <button\n @click=\"viewMode = 'json'\"\n :class=\"viewMode === 'json'\n ? 'bg-blue-600 text-white z-10'\n : 'bg-gray-200 text-gray-700 hover:bg-gray-300'\"\n class=\"px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-gray-300 rounded-r-lg rounded-l-none\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4\"></path>\n </svg>\n JSON\n </button>\n </div>\n\n <div class=\"gap-2 hidden md:flex items-center\">\n <button\n v-if=\"!editting\"\n @click=\"editting = true\"\n :disabled=\"!canEdit\"\n :class=\"{'cursor-not-allowed opacity-50': !canEdit}\"\n type=\"button\"\n class=\"rounded-md bg-ultramarine-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-ultramarine-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600\">\n <img src=\"images/edit.svg\" class=\"inline\" /> Edit\n </button>\n <button\n v-if=\"editting\"\n @click=\"editting = false\"\n type=\"button\"\n class=\"rounded-md bg-slate-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-600\">\n &times; Cancel\n </button>\n <button\n v-if=\"editting\"\n :disabled=\"!canManipulate\"\n :class=\"{'cursor-not-allowed opacity-50': !canManipulate}\"\n @click=\"shouldShowConfirmModal=true;\"\n type=\"button\"\n class=\"rounded-md bg-forest-green-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600\">\n <img src=\"images/save.svg\" class=\"inline\" /> Save\n </button>\n\n <!-- 3-dot menu -->\n <div class=\"relative\">\n <button\n @click=\"desktopMenuOpen = !desktopMenuOpen\"\n type=\"button\"\n class=\"inline-flex items-center justify-center rounded-md bg-gray-200 px-2.5 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\"\n aria-expanded=\"desktopMenuOpen\"\n aria-label=\"More options\"\n >\n <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <circle cx=\"12\" cy=\"5\" r=\"2\"></circle>\n <circle cx=\"12\" cy=\"12\" r=\"2\"></circle>\n <circle cx=\"12\" cy=\"19\" r=\"2\"></circle>\n </svg>\n </button>\n <div\n v-show=\"desktopMenuOpen\"\n @click.away=\"desktopMenuOpen = false\"\n class=\"origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50\"\n >\n <div class=\"py-1 flex flex-col\">\n <button\n @click=\"addField(); desktopMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-green-100\"\n >\n Add Field\n </button>\n <button\n @click=\"shouldShowDeleteModal=true; desktopMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-red-100']\"\n type=\"button\"\n >\n Delete\n </button>\n <button\n @click=\"shouldShowCloneModal=true; desktopMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-pink-100']\"\n type=\"button\"\n >\n Clone\n </button>\n </div>\n </div>\n </div>\n </div>\n <div class=\"md:hidden flex items-center\">\n <div class=\"relative\">\n <button\n @click=\"mobileMenuOpen = !mobileMenuOpen\"\n type=\"button\"\n class=\"inline-flex items-center justify-center rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\"\n aria-expanded=\"mobileMenuOpen\"\n aria-label=\"Open menu\"\n >\n <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n d=\"M4 6h16M4 12h16M4 18h16\"></path>\n </svg>\n </button>\n <div\n v-show=\"mobileMenuOpen\"\n @click.away=\"mobileMenuOpen = false\"\n class=\"origin-top-right absolute right-0 mt-2 w-52 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50\"\n >\n <div class=\"py-1 flex flex-col\">\n <button\n v-if=\"!editting\"\n @click=\"editting = true; mobileMenuOpen = false\"\n :disabled=\"!canEdit\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canEdit ? 'cursor-not-allowed opacity-50' : 'hover:bg-ultramarine-100']\"\n type=\"button\"\n >\n Edit\n </button>\n <button\n v-if=\"editting\"\n @click=\"editting = false; mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100\"\n >\n Cancel\n </button>\n <button\n v-if=\"editting\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-green-100']\"\n @click=\"shouldShowConfirmModal=true; mobileMenuOpen = false\"\n type=\"button\"\n >\n Save\n </button>\n <button\n @click=\"addField(); mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-green-100\"\n >\n Add Field\n </button>\n <button\n @click=\"shouldShowDeleteModal=true; mobileMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-red-100']\"\n type=\"button\"\n >\n Delete\n </button>\n <button\n @click=\"shouldShowCloneModal=true; mobileMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-pink-100']\"\n type=\"button\"\n >\n Clone\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div v-if=\"status === 'loaded'\">\n <document-details\n :document=\"document\"\n :schemaPaths=\"schemaPaths\"\n :virtualPaths=\"virtualPaths\"\n :editting=\"editting\"\n :changes=\"changes\"\n :invalid=\"invalid\"\n :viewMode=\"viewMode\"\n :model=\"model\"\n @add-field=\"addField\"\n @view-mode-change=\"updateViewMode\"></document-details>\n <modal v-if=\"shouldShowConfirmModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowConfirmModal = false;\">&times;</div>\n <confirm-changes @close=\"shouldShowConfirmModal = false;\" @save=\"save\" :value=\"changes\"></confirm-changes>\n </template>\n </modal>\n <modal v-if=\"shouldShowDeleteModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowDeleteModal = false;\">&times;</div>\n <confirm-delete @close=\"shouldShowDeleteModal = false;\" @remove=\"remove\" :value=\"document\"></confirm-delete>\n </template>\n </modal>\n <modal v-if=\"shouldShowCloneModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowCloneModal = false;\">&times;</div>\n <clone-document :currentModel=\"model\" :doc=\"document\" :schemaPaths=\"schemaPaths\" @close=\"showClonedDocument\"></clone-document>\n </template>\n </modal>\n </div>\n </div>\n</div>\n";
4955
+ module.exports = "<div class=\"document px-1 pt-4 pb-16 md:px-0 bg-slate-50 w-full\">\n <div class=\"max-w-7xl mx-auto\">\n <div class=\"flex gap-4 items-center sticky top-0 z-50 bg-white p-4 border-b border-gray-200 shadow-sm\">\n <div class=\"font-bold overflow-hidden text-ellipsis\">{{model}}: {{documentId}}</div>\n <div class=\"flex grow\">\n <button\n @click=\"viewMode = 'fields'\"\n :class=\"viewMode === 'fields'\n ? 'bg-blue-600 text-white z-10'\n : 'bg-gray-200 text-gray-700 hover:bg-gray-300'\"\n class=\"px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-gray-300 border-r-0 rounded-l-lg rounded-r-none\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 6h16M4 10h16M4 14h16M4 18h16\"></path>\n </svg>\n Fields\n </button>\n <button\n @click=\"viewMode = 'json'\"\n :class=\"viewMode === 'json'\n ? 'bg-blue-600 text-white z-10'\n : 'bg-gray-200 text-gray-700 hover:bg-gray-300'\"\n class=\"px-2 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center gap-2 border border-gray-300 rounded-r-lg rounded-l-none\"\n >\n <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4\"></path>\n </svg>\n JSON\n </button>\n </div>\n\n <div class=\"hidden md:flex items-center gap-3 text-sm text-slate-600\">\n <div class=\"text-right leading-tight\">\n <div class=\"text-xs uppercase tracking-wide text-slate-400 flex items-center gap-2 justify-end\">\n <span>Loaded at</span>\n <span class=\"inline-flex items-center gap-1 text-[10px] font-semibold\" :class=\"autoRefreshEnabled ? 'text-forest-green-600' : 'text-slate-400'\">\n <span class=\"inline-block h-1.5 w-1.5 rounded-full\" :class=\"autoRefreshEnabled ? 'bg-forest-green-500' : 'bg-slate-300'\"></span>\n </span>\n </div>\n <div class=\"font-medium text-slate-700\">{{lastUpdatedLabel}}</div>\n </div>\n </div>\n\n <div class=\"gap-2 hidden md:flex items-center\">\n <button\n v-if=\"!editting\"\n @click=\"editting = true\"\n :disabled=\"!canEdit\"\n :class=\"{'cursor-not-allowed opacity-50': !canEdit}\"\n type=\"button\"\n class=\"rounded-md bg-ultramarine-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-ultramarine-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600\">\n <img src=\"images/edit.svg\" class=\"inline\" /> Edit\n </button>\n <button\n v-if=\"editting\"\n @click=\"editting = false\"\n type=\"button\"\n class=\"rounded-md bg-slate-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-slate-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-600\">\n &times; Cancel\n </button>\n <button\n v-if=\"editting\"\n :disabled=\"!canManipulate\"\n :class=\"{'cursor-not-allowed opacity-50': !canManipulate}\"\n @click=\"shouldShowConfirmModal=true;\"\n type=\"button\"\n class=\"rounded-md bg-forest-green-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600\">\n <img src=\"images/save.svg\" class=\"inline\" /> Save\n </button>\n\n <!-- 3-dot menu -->\n <div class=\"relative\">\n <button\n @click=\"desktopMenuOpen = !desktopMenuOpen\"\n type=\"button\"\n class=\"inline-flex items-center justify-center rounded-md bg-gray-200 px-2.5 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\"\n aria-expanded=\"desktopMenuOpen\"\n aria-label=\"More options\"\n >\n <svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <circle cx=\"12\" cy=\"5\" r=\"2\"></circle>\n <circle cx=\"12\" cy=\"12\" r=\"2\"></circle>\n <circle cx=\"12\" cy=\"19\" r=\"2\"></circle>\n </svg>\n </button>\n <div\n v-show=\"desktopMenuOpen\"\n @click.away=\"desktopMenuOpen = false\"\n class=\"origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50\"\n >\n <div class=\"py-1 flex flex-col\">\n <button\n @click=\"refreshDocument({ force: true, source: 'manual' }); desktopMenuOpen = false\"\n :disabled=\"isRefreshing\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']\"\n type=\"button\"\n >\n {{isRefreshing ? 'Refreshing...' : 'Refresh'}}\n </button>\n <button\n @click=\"toggleAutoRefresh(); desktopMenuOpen = false\"\n :disabled=\"isLambda\"\n type=\"button\"\n :class=\"['flex items-center px-4 py-2 text-sm', isLambda ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700 hover:bg-slate-100']\"\n :title=\"isLambda ? 'Auto-refresh only available on Express deployments' : ''\"\n >\n {{autoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh'}}\n </button>\n <button\n @click=\"addField(); desktopMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-green-100\"\n >\n Add Field\n </button>\n <button\n @click=\"copyDocument(); desktopMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100\"\n >\n Copy Document\n </button>\n <button\n @click=\"shouldShowDeleteModal=true; desktopMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-red-100']\"\n type=\"button\"\n >\n Delete\n </button>\n <button\n @click=\"shouldShowCloneModal=true; desktopMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-pink-100']\"\n type=\"button\"\n >\n Clone\n </button>\n </div>\n </div>\n </div>\n </div>\n <div class=\"md:hidden flex items-center\">\n <div class=\"relative\">\n <button\n @click=\"mobileMenuOpen = !mobileMenuOpen\"\n type=\"button\"\n class=\"inline-flex items-center justify-center rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\"\n aria-expanded=\"mobileMenuOpen\"\n aria-label=\"Open menu\"\n >\n <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n d=\"M4 6h16M4 12h16M4 18h16\"></path>\n </svg>\n </button>\n <div\n v-show=\"mobileMenuOpen\"\n @click.away=\"mobileMenuOpen = false\"\n class=\"origin-top-right absolute right-0 mt-2 w-52 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50\"\n >\n <div class=\"py-1 flex flex-col\">\n <button\n v-if=\"!editting\"\n @click=\"editting = true; mobileMenuOpen = false\"\n :disabled=\"!canEdit\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canEdit ? 'cursor-not-allowed opacity-50' : 'hover:bg-ultramarine-100']\"\n type=\"button\"\n >\n Edit\n </button>\n <button\n v-if=\"editting\"\n @click=\"editting = false; mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100\"\n >\n Cancel\n </button>\n <button\n v-if=\"editting\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-green-100']\"\n @click=\"shouldShowConfirmModal=true; mobileMenuOpen = false\"\n type=\"button\"\n >\n Save\n </button>\n <button\n @click=\"refreshDocument({ force: true, source: 'manual' }); mobileMenuOpen = false\"\n :disabled=\"isRefreshing\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']\"\n type=\"button\"\n >\n {{isRefreshing ? 'Refreshing...' : 'Refresh'}}\n </button>\n <button\n @click=\"toggleAutoRefresh(); mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100\"\n >\n Auto-Refresh {{autoRefreshEnabled ? 'ON' : 'OFF'}}\n </button>\n <button\n @click=\"addField(); mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-green-100\"\n >\n Add Field\n </button>\n <button\n @click=\"copyDocument(); mobileMenuOpen = false\"\n type=\"button\"\n class=\"flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100\"\n >\n Copy Document\n </button>\n <button\n @click=\"shouldShowDeleteModal=true; mobileMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-red-100']\"\n type=\"button\"\n >\n Delete\n </button>\n <button\n @click=\"shouldShowCloneModal=true; mobileMenuOpen = false\"\n :disabled=\"!canManipulate\"\n :class=\"['flex items-center px-4 py-2 text-sm text-gray-700', !canManipulate ? 'cursor-not-allowed opacity-50' : 'hover:bg-pink-100']\"\n type=\"button\"\n >\n Clone\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n <div v-if=\"status === 'loaded'\">\n <document-details\n :document=\"document\"\n :schemaPaths=\"schemaPaths\"\n :virtualPaths=\"virtualPaths\"\n :editting=\"editting\"\n :changes=\"changes\"\n :invalid=\"invalid\"\n :viewMode=\"viewMode\"\n :model=\"model\"\n @add-field=\"addField\"\n @view-mode-change=\"updateViewMode\"></document-details>\n <modal v-if=\"shouldShowConfirmModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowConfirmModal = false;\">&times;</div>\n <confirm-changes @close=\"shouldShowConfirmModal = false;\" @save=\"save\" :value=\"changes\"></confirm-changes>\n </template>\n </modal>\n <modal v-if=\"shouldShowDeleteModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowDeleteModal = false;\">&times;</div>\n <confirm-delete @close=\"shouldShowDeleteModal = false;\" @remove=\"remove\" :value=\"document\"></confirm-delete>\n </template>\n </modal>\n <modal v-if=\"shouldShowCloneModal\">\n <template v-slot:body>\n <div class=\"modal-exit\" @click=\"shouldShowCloneModal = false;\">&times;</div>\n <clone-document :currentModel=\"model\" :doc=\"document\" :schemaPaths=\"schemaPaths\" @close=\"showClonedDocument\"></clone-document>\n </template>\n </modal>\n </div>\n </div>\n</div>\n";
4897
4956
 
4898
4957
  /***/ },
4899
4958
 
@@ -4932,30 +4991,25 @@ module.exports = app => app.component('document', {
4932
4991
  shouldShowConfirmModal: false,
4933
4992
  shouldShowDeleteModal: false,
4934
4993
  shouldShowCloneModal: false,
4935
- previousQuery: null
4994
+ previousQuery: null,
4995
+ lastUpdatedAt: null,
4996
+ isRefreshing: false,
4997
+ autoRefreshEnabled: false,
4998
+ autoRefreshConnecting: false,
4999
+ autoRefreshLoopRunning: false,
5000
+ autoRefreshError: null,
5001
+ autoRefreshAbortController: null,
5002
+ autoRefreshRetryTimer: null,
5003
+ pendingRefresh: false
4936
5004
  }),
4937
5005
  async mounted() {
4938
5006
  window.pageState = this;
4939
5007
  // Store query parameters from the route (preserved from models page)
4940
5008
  this.previousQuery = Object.assign({}, this.$route.query);
4941
- try {
4942
- const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({ model: this.model, documentId: this.documentId });
4943
- window.doc = doc;
4944
- this.document = doc;
4945
- this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
4946
- if (k1 === '_id' && k2 !== '_id') {
4947
- return -1;
4948
- }
4949
- if (k1 !== '_id' && k2 === '_id') {
4950
- return 1;
4951
- }
4952
- return 0;
4953
- }).map(key => schemaPaths[key]);
4954
- this.virtualPaths = virtualPaths || [];
4955
- this.status = 'loaded';
4956
- } finally {
4957
- this.status = 'loaded';
4958
- }
5009
+ await this.refreshDocument({ force: true, source: 'initial' });
5010
+ },
5011
+ beforeDestroy() {
5012
+ this.stopAutoRefresh();
4959
5013
  },
4960
5014
  computed: {
4961
5015
  canManipulate() {
@@ -4967,8 +5021,32 @@ module.exports = app => app.component('document', {
4967
5021
  }
4968
5022
  return !this.roles.includes('readonly');
4969
5023
  },
5024
+ lastUpdatedLabel() {
5025
+ if (!this.lastUpdatedAt) {
5026
+ return '—';
5027
+ }
5028
+ try {
5029
+ return new Date(this.lastUpdatedAt).toLocaleTimeString([], {
5030
+ hour: '2-digit',
5031
+ minute: '2-digit',
5032
+ second: '2-digit'
5033
+ });
5034
+ } catch (err) {
5035
+ return '—';
5036
+ }
5037
+ },
4970
5038
  canEdit() {
4971
5039
  return this.canManipulate && this.viewMode === 'fields';
5040
+ },
5041
+ isLambda() {
5042
+ return !!window?.MONGOOSE_STUDIO_CONFIG?.isLambda;
5043
+ }
5044
+ },
5045
+ watch: {
5046
+ editting(nextValue) {
5047
+ if (!nextValue && this.pendingRefresh) {
5048
+ this.refreshDocument({ source: 'pending' });
5049
+ }
4972
5050
  }
4973
5051
  },
4974
5052
  methods: {
@@ -4976,6 +5054,125 @@ module.exports = app => app.component('document', {
4976
5054
  this.changes = {};
4977
5055
  this.editting = false;
4978
5056
  },
5057
+ async refreshDocument(options = {}) {
5058
+ const { force = false, source = 'manual' } = options;
5059
+ if (this.editting && !force) {
5060
+ this.pendingRefresh = true;
5061
+ return;
5062
+ }
5063
+ if (this.isRefreshing) {
5064
+ this.pendingRefresh = true;
5065
+ return;
5066
+ }
5067
+
5068
+ const isInitial = this.status === 'init';
5069
+ if (isInitial) {
5070
+ this.status = 'loading';
5071
+ }
5072
+ this.isRefreshing = true;
5073
+ this.autoRefreshError = null;
5074
+ try {
5075
+ const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({
5076
+ model: this.model,
5077
+ documentId: this.documentId
5078
+ });
5079
+ window.doc = doc;
5080
+ this.document = doc;
5081
+ this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
5082
+ if (k1 === '_id' && k2 !== '_id') {
5083
+ return -1;
5084
+ }
5085
+ if (k1 !== '_id' && k2 === '_id') {
5086
+ return 1;
5087
+ }
5088
+ return 0;
5089
+ }).map(key => schemaPaths[key]);
5090
+ this.virtualPaths = virtualPaths || [];
5091
+ this.lastUpdatedAt = new Date();
5092
+ this.pendingRefresh = false;
5093
+ } catch (err) {
5094
+ console.error('Error refreshing document:', err);
5095
+ if (this.$toast && source !== 'initial') {
5096
+ this.$toast.error('Failed to refresh document');
5097
+ }
5098
+ } finally {
5099
+ this.status = 'loaded';
5100
+ this.isRefreshing = false;
5101
+ if (this.pendingRefresh && !this.editting) {
5102
+ this.pendingRefresh = false;
5103
+ this.refreshDocument({ source: 'pending' });
5104
+ }
5105
+ }
5106
+ },
5107
+ toggleAutoRefresh() {
5108
+ if (this.autoRefreshEnabled) {
5109
+ this.stopAutoRefresh();
5110
+ } else {
5111
+ this.startAutoRefresh();
5112
+ }
5113
+ },
5114
+ startAutoRefresh() {
5115
+ if (this.autoRefreshEnabled) {
5116
+ return;
5117
+ }
5118
+ this.autoRefreshEnabled = true;
5119
+ this.autoRefreshError = null;
5120
+ this.runAutoRefreshLoop();
5121
+ },
5122
+ stopAutoRefresh() {
5123
+ this.autoRefreshEnabled = false;
5124
+ this.autoRefreshConnecting = false;
5125
+ if (this.autoRefreshAbortController) {
5126
+ this.autoRefreshAbortController.abort();
5127
+ this.autoRefreshAbortController = null;
5128
+ }
5129
+ if (this.autoRefreshRetryTimer) {
5130
+ clearTimeout(this.autoRefreshRetryTimer);
5131
+ this.autoRefreshRetryTimer = null;
5132
+ }
5133
+ },
5134
+ async runAutoRefreshLoop() {
5135
+ if (this.autoRefreshLoopRunning) {
5136
+ return;
5137
+ }
5138
+ this.autoRefreshLoopRunning = true;
5139
+ this.autoRefreshAbortController = new AbortController();
5140
+ let retryDelay = 1500;
5141
+
5142
+ while (this.autoRefreshEnabled && !this.autoRefreshAbortController.signal.aborted) {
5143
+ try {
5144
+ this.autoRefreshConnecting = true;
5145
+ for await (const event of api.Model.streamDocumentChanges({
5146
+ model: this.model,
5147
+ documentId: this.documentId
5148
+ }, { signal: this.autoRefreshAbortController.signal })) {
5149
+ this.autoRefreshConnecting = false;
5150
+ if (!event || event.type === 'heartbeat') {
5151
+ continue;
5152
+ }
5153
+ await this.refreshDocument({ source: 'auto' });
5154
+ }
5155
+ } catch (err) {
5156
+ if (this.autoRefreshAbortController.signal.aborted) {
5157
+ break;
5158
+ }
5159
+ this.autoRefreshError = err?.message || String(err);
5160
+ } finally {
5161
+ this.autoRefreshConnecting = false;
5162
+ }
5163
+
5164
+ if (!this.autoRefreshEnabled || this.autoRefreshAbortController.signal.aborted) {
5165
+ break;
5166
+ }
5167
+
5168
+ await new Promise(resolve => {
5169
+ this.autoRefreshRetryTimer = setTimeout(resolve, retryDelay);
5170
+ });
5171
+ retryDelay = Math.min(retryDelay * 2, 15000);
5172
+ }
5173
+
5174
+ this.autoRefreshLoopRunning = false;
5175
+ },
4979
5176
  async save() {
4980
5177
  if (Object.keys(this.invalid).length > 0) {
4981
5178
  throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
@@ -5043,6 +5240,43 @@ module.exports = app => app.component('document', {
5043
5240
  this.changes = {};
5044
5241
  }
5045
5242
  },
5243
+ copyDocument() {
5244
+ if (!this.document) {
5245
+ return;
5246
+ }
5247
+
5248
+ const textToCopy = JSON.stringify(this.document, null, 2);
5249
+ const fallbackCopy = () => {
5250
+ if (typeof document === 'undefined') {
5251
+ return;
5252
+ }
5253
+ const textArea = document.createElement('textarea');
5254
+ textArea.value = textToCopy;
5255
+ textArea.setAttribute('readonly', '');
5256
+ textArea.style.position = 'absolute';
5257
+ textArea.style.left = '-9999px';
5258
+ document.body.appendChild(textArea);
5259
+ textArea.select();
5260
+ try {
5261
+ document.execCommand('copy');
5262
+ } finally {
5263
+ document.body.removeChild(textArea);
5264
+ }
5265
+ this.$toast.success('Document copied!');
5266
+ };
5267
+
5268
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
5269
+ navigator.clipboard.writeText(textToCopy)
5270
+ .then(() => {
5271
+ this.$toast.success('Document copied!');
5272
+ })
5273
+ .catch(() => {
5274
+ fallbackCopy();
5275
+ });
5276
+ } else {
5277
+ fallbackCopy();
5278
+ }
5279
+ },
5046
5280
  goBack() {
5047
5281
  // Preserve query parameters when going back to models page
5048
5282
  this.$router.push({
@@ -5869,8 +6103,12 @@ app.component('app-component', {
5869
6103
  const { user, roles } = await mothership.me();
5870
6104
 
5871
6105
  try {
5872
- const { nodeEnv } = await api.status();
6106
+ const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
6107
+ api.status(),
6108
+ api.Model.listModels()
6109
+ ]);
5873
6110
  this.nodeEnv = nodeEnv;
6111
+ this.modelSchemaPaths = modelSchemaPaths;
5874
6112
  } catch (err) {
5875
6113
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
5876
6114
  this.status = 'loaded';
@@ -5883,8 +6121,12 @@ app.component('app-component', {
5883
6121
  }
5884
6122
  } else {
5885
6123
  try {
5886
- const { nodeEnv } = await api.status();
6124
+ const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
6125
+ api.status(),
6126
+ api.Model.listModels()
6127
+ ]);
5887
6128
  this.nodeEnv = nodeEnv;
6129
+ this.modelSchemaPaths = modelSchemaPaths;
5888
6130
  } catch (err) {
5889
6131
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
5890
6132
  }
@@ -5898,8 +6140,9 @@ app.component('app-component', {
5898
6140
  const status = Vue.ref('init');
5899
6141
  const nodeEnv = Vue.ref(null);
5900
6142
  const authError = Vue.ref(null);
6143
+ const modelSchemaPaths = Vue.ref(null);
5901
6144
 
5902
- const state = Vue.reactive({ user, roles, status, nodeEnv, authError });
6145
+ const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths });
5903
6146
  Vue.provide('state', state);
5904
6147
 
5905
6148
  return state;
@@ -35732,7 +35975,7 @@ module.exports = FilterXSS;
35732
35975
  (module) {
35733
35976
 
35734
35977
  "use strict";
35735
- module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.2.9","description":"A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.","homepage":"https://mongoosestudio.app/","repository":{"type":"git","url":"https://github.com/mongoosejs/studio"},"license":"Apache-2.0","dependencies":{"@ai-sdk/anthropic":"2.x","@ai-sdk/google":"2.x","@ai-sdk/openai":"2.x","ai":"5.x","archetype":"0.13.1","csv-stringify":"6.3.0","ejson":"^2.2.3","extrovert":"^0.2.0","marked":"15.0.12","node-inspect-extracted":"3.x","tailwindcss":"3.4.0","vue":"3.x","vue-toastification":"^2.0.0-rc.5","webpack":"5.x","xss":"^1.0.15"},"peerDependencies":{"mongoose":"7.x || 8.x || ^9.0.0"},"devDependencies":{"@masteringjs/eslint-config":"0.1.1","axios":"1.2.2","dedent":"^1.6.0","eslint":"9.30.0","express":"4.x","mocha":"10.2.0","mongoose":"9.x","sinon":"^21.0.1"},"scripts":{"lint":"eslint .","tailwind":"tailwindcss -o ./frontend/public/tw.css","tailwind:watch":"tailwindcss -o ./frontend/public/tw.css --watch","test":"mocha test/*.test.js","test:frontend":"mocha test/frontend/*.test.js"}}');
35978
+ module.exports = /*#__PURE__*/JSON.parse('{"name":"@mongoosejs/studio","version":"0.2.10","description":"A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.","homepage":"https://mongoosestudio.app/","repository":{"type":"git","url":"https://github.com/mongoosejs/studio"},"license":"Apache-2.0","dependencies":{"@ai-sdk/anthropic":"2.x","@ai-sdk/google":"2.x","@ai-sdk/openai":"2.x","ai":"5.x","archetype":"0.13.1","csv-stringify":"6.3.0","ejson":"^2.2.3","extrovert":"^0.2.0","marked":"15.0.12","node-inspect-extracted":"3.x","tailwindcss":"3.4.0","vue":"3.x","vue-toastification":"^2.0.0-rc.5","webpack":"5.x","xss":"^1.0.15"},"peerDependencies":{"mongoose":"7.x || 8.x || ^9.0.0"},"devDependencies":{"@masteringjs/eslint-config":"0.1.1","axios":"1.2.2","dedent":"^1.6.0","eslint":"9.30.0","express":"4.x","mocha":"10.2.0","mongoose":"9.x","sinon":"^21.0.1"},"scripts":{"lint":"eslint .","tailwind":"tailwindcss -o ./frontend/public/tw.css","tailwind:watch":"tailwindcss -o ./frontend/public/tw.css --watch","test":"mocha test/*.test.js","test:frontend":"mocha test/frontend/*.test.js"}}');
35736
35979
 
35737
35980
  /***/ }
35738
35981
 
@@ -950,6 +950,14 @@ video {
950
950
  height: 0px;
951
951
  }
952
952
 
953
+ .h-1 {
954
+ height: 0.25rem;
955
+ }
956
+
957
+ .h-1\.5 {
958
+ height: 0.375rem;
959
+ }
960
+
953
961
  .h-10 {
954
962
  height: 2.5rem;
955
963
  }
@@ -1062,6 +1070,14 @@ video {
1062
1070
  width: 0px;
1063
1071
  }
1064
1072
 
1073
+ .w-1 {
1074
+ width: 0.25rem;
1075
+ }
1076
+
1077
+ .w-1\.5 {
1078
+ width: 0.375rem;
1079
+ }
1080
+
1065
1081
  .w-12 {
1066
1082
  width: 3rem;
1067
1083
  }
@@ -1661,6 +1677,11 @@ video {
1661
1677
  background-color: rgb(5 150 105 / var(--tw-bg-opacity));
1662
1678
  }
1663
1679
 
1680
+ .bg-forest-green-500 {
1681
+ --tw-bg-opacity: 1;
1682
+ background-color: rgb(0 242 58 / var(--tw-bg-opacity));
1683
+ }
1684
+
1664
1685
  .bg-forest-green-600 {
1665
1686
  --tw-bg-opacity: 1;
1666
1687
  background-color: rgb(0 202 44 / var(--tw-bg-opacity));
@@ -1751,6 +1772,11 @@ video {
1751
1772
  background-color: rgb(241 245 249 / var(--tw-bg-opacity));
1752
1773
  }
1753
1774
 
1775
+ .bg-slate-300 {
1776
+ --tw-bg-opacity: 1;
1777
+ background-color: rgb(203 213 225 / var(--tw-bg-opacity));
1778
+ }
1779
+
1754
1780
  .bg-slate-50 {
1755
1781
  --tw-bg-opacity: 1;
1756
1782
  background-color: rgb(248 250 252 / var(--tw-bg-opacity));
@@ -2124,6 +2150,11 @@ video {
2124
2150
  color: rgb(0 242 58 / var(--tw-text-opacity));
2125
2151
  }
2126
2152
 
2153
+ .text-forest-green-600 {
2154
+ --tw-text-opacity: 1;
2155
+ color: rgb(0 202 44 / var(--tw-text-opacity));
2156
+ }
2157
+
2127
2158
  .text-gray-300 {
2128
2159
  --tw-text-opacity: 1;
2129
2160
  color: rgb(209 213 219 / var(--tw-text-opacity));
@@ -2204,11 +2235,21 @@ video {
2204
2235
  color: rgb(7 89 133 / var(--tw-text-opacity));
2205
2236
  }
2206
2237
 
2238
+ .text-slate-400 {
2239
+ --tw-text-opacity: 1;
2240
+ color: rgb(148 163 184 / var(--tw-text-opacity));
2241
+ }
2242
+
2207
2243
  .text-slate-500 {
2208
2244
  --tw-text-opacity: 1;
2209
2245
  color: rgb(100 116 139 / var(--tw-text-opacity));
2210
2246
  }
2211
2247
 
2248
+ .text-slate-600 {
2249
+ --tw-text-opacity: 1;
2250
+ color: rgb(71 85 105 / var(--tw-text-opacity));
2251
+ }
2252
+
2212
2253
  .text-slate-700 {
2213
2254
  --tw-text-opacity: 1;
2214
2255
  color: rgb(51 65 85 / var(--tw-text-opacity));
@@ -2502,6 +2543,11 @@ video {
2502
2543
  background-color: rgb(253 230 138 / var(--tw-bg-opacity));
2503
2544
  }
2504
2545
 
2546
+ .hover\:bg-blue-100:hover {
2547
+ --tw-bg-opacity: 1;
2548
+ background-color: rgb(219 234 254 / var(--tw-bg-opacity));
2549
+ }
2550
+
2505
2551
  .hover\:bg-blue-600:hover {
2506
2552
  --tw-bg-opacity: 1;
2507
2553
  background-color: rgb(37 99 235 / var(--tw-bg-opacity));
@@ -132,6 +132,16 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
132
132
  yield { document: doc };
133
133
  }
134
134
  },
135
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
136
+ const pollIntervalMs = 5000;
137
+ while (!options.signal?.aborted) {
138
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
139
+ if (options.signal?.aborted) {
140
+ return;
141
+ }
142
+ yield { type: 'poll', model: params.model, documentId: params.documentId };
143
+ }
144
+ },
135
145
  getCollectionInfo: function getCollectionInfo(params) {
136
146
  return client.post('', { action: 'Model.getCollectionInfo', ...params }).then(res => res.data);
137
147
  },
@@ -352,6 +362,56 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) {
352
362
  }
353
363
  }
354
364
  },
365
+ streamDocumentChanges: async function* streamDocumentChanges(params, options = {}) {
366
+ const accessToken = window.localStorage.getItem('_mongooseStudioAccessToken') || null;
367
+ const url = window.MONGOOSE_STUDIO_CONFIG.baseURL + '/Model/streamDocumentChanges?' + new URLSearchParams(params).toString();
368
+
369
+ const response = await fetch(url, {
370
+ method: 'GET',
371
+ headers: {
372
+ Authorization: `${accessToken}`,
373
+ Accept: 'text/event-stream'
374
+ },
375
+ signal: options.signal
376
+ });
377
+
378
+ if (!response.ok) {
379
+ throw new Error(`HTTP error! Status: ${response.status}`);
380
+ }
381
+
382
+ const reader = response.body.getReader();
383
+ const decoder = new TextDecoder('utf-8');
384
+ let buffer = '';
385
+
386
+ while (true) {
387
+ const { done, value } = await reader.read();
388
+ if (done) break;
389
+ buffer += decoder.decode(value, { stream: true });
390
+
391
+ let eventEnd;
392
+ while ((eventEnd = buffer.indexOf('\n\n')) !== -1) {
393
+ const eventStr = buffer.slice(0, eventEnd);
394
+ buffer = buffer.slice(eventEnd + 2);
395
+
396
+ // Parse SSE event
397
+ const lines = eventStr.split('\n');
398
+ let data = '';
399
+ for (const line of lines) {
400
+ if (line.startsWith('data:')) {
401
+ data += line.slice(5).trim();
402
+ }
403
+ }
404
+ if (data) {
405
+ try {
406
+ yield JSON.parse(data);
407
+ } catch (err) {
408
+ // If not JSON, yield as string
409
+ yield data;
410
+ }
411
+ }
412
+ }
413
+ }
414
+ },
355
415
  getCollectionInfo: function getCollectionInfo(params) {
356
416
  return client.post('/Model/getCollectionInfo', params).then(res => res.data);
357
417
  },
@@ -1,8 +1,7 @@
1
1
  <div class="py-2">
2
- <div v-if="header" class="border-b border-gray-100 px-2 pb-2 text-xl font-bold">
3
- {{header}}
4
- </div>
5
2
  <div class="text-xl pb-2">
6
- <document-details :document="value.$document.document" :schemaPaths="schemaPaths" />
3
+ <list-json
4
+ :value="value.$document.document"
5
+ :references="references" />
7
6
  </div>
8
- </div>
7
+ </div>
@@ -7,23 +7,22 @@ const template = require('./dashboard-document.html');
7
7
  module.exports = app => app.component('dashboard-document', {
8
8
  template: template,
9
9
  props: ['value'],
10
+ inject: ['state'],
10
11
  computed: {
11
- header() {
12
- if (this.value != null && this.value.$document.header) {
13
- return this.value.$document.header;
12
+ references() {
13
+ const model = this.value.$document?.model;
14
+ if (typeof model === 'string' && this.state.modelSchemaPaths?.[model]) {
15
+ const map = {};
16
+ for (const path of Object.keys(this.state.modelSchemaPaths[model])) {
17
+ const definition = this.state.modelSchemaPaths[model][path];
18
+ if (definition?.ref) {
19
+ map[path] = definition.ref;
20
+ }
21
+ }
22
+ return map;
14
23
  }
24
+
15
25
  return null;
16
- },
17
- schemaPaths() {
18
- return Object.keys(this.value?.$document?.schemaPaths || {}).sort((k1, k2) => {
19
- if (k1 === '_id' && k2 !== '_id') {
20
- return -1;
21
- }
22
- if (k1 !== '_id' && k2 === '_id') {
23
- return 1;
24
- }
25
- return 0;
26
- }).map(key => this.value?.$document.schemaPaths[key]);
27
26
  }
28
27
  }
29
28
  });
@@ -29,6 +29,18 @@
29
29
  </button>
30
30
  </div>
31
31
 
32
+ <div class="hidden md:flex items-center gap-3 text-sm text-slate-600">
33
+ <div class="text-right leading-tight">
34
+ <div class="text-xs uppercase tracking-wide text-slate-400 flex items-center gap-2 justify-end">
35
+ <span>Loaded at</span>
36
+ <span class="inline-flex items-center gap-1 text-[10px] font-semibold" :class="autoRefreshEnabled ? 'text-forest-green-600' : 'text-slate-400'">
37
+ <span class="inline-block h-1.5 w-1.5 rounded-full" :class="autoRefreshEnabled ? 'bg-forest-green-500' : 'bg-slate-300'"></span>
38
+ </span>
39
+ </div>
40
+ <div class="font-medium text-slate-700">{{lastUpdatedLabel}}</div>
41
+ </div>
42
+ </div>
43
+
32
44
  <div class="gap-2 hidden md:flex items-center">
33
45
  <button
34
46
  v-if="!editting"
@@ -77,6 +89,23 @@
77
89
  class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50"
78
90
  >
79
91
  <div class="py-1 flex flex-col">
92
+ <button
93
+ @click="refreshDocument({ force: true, source: 'manual' }); desktopMenuOpen = false"
94
+ :disabled="isRefreshing"
95
+ :class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
96
+ type="button"
97
+ >
98
+ {{isRefreshing ? 'Refreshing...' : 'Refresh'}}
99
+ </button>
100
+ <button
101
+ @click="toggleAutoRefresh(); desktopMenuOpen = false"
102
+ :disabled="isLambda"
103
+ type="button"
104
+ :class="['flex items-center px-4 py-2 text-sm', isLambda ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700 hover:bg-slate-100']"
105
+ :title="isLambda ? 'Auto-refresh only available on Express deployments' : ''"
106
+ >
107
+ {{autoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh'}}
108
+ </button>
80
109
  <button
81
110
  @click="addField(); desktopMenuOpen = false"
82
111
  type="button"
@@ -84,6 +113,13 @@
84
113
  >
85
114
  Add Field
86
115
  </button>
116
+ <button
117
+ @click="copyDocument(); desktopMenuOpen = false"
118
+ type="button"
119
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
120
+ >
121
+ Copy Document
122
+ </button>
87
123
  <button
88
124
  @click="shouldShowDeleteModal=true; desktopMenuOpen = false"
89
125
  :disabled="!canManipulate"
@@ -150,6 +186,21 @@
150
186
  >
151
187
  Save
152
188
  </button>
189
+ <button
190
+ @click="refreshDocument({ force: true, source: 'manual' }); mobileMenuOpen = false"
191
+ :disabled="isRefreshing"
192
+ :class="['flex items-center px-4 py-2 text-sm text-gray-700', isRefreshing ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-100']"
193
+ type="button"
194
+ >
195
+ {{isRefreshing ? 'Refreshing...' : 'Refresh'}}
196
+ </button>
197
+ <button
198
+ @click="toggleAutoRefresh(); mobileMenuOpen = false"
199
+ type="button"
200
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-slate-100"
201
+ >
202
+ Auto-Refresh {{autoRefreshEnabled ? 'ON' : 'OFF'}}
203
+ </button>
153
204
  <button
154
205
  @click="addField(); mobileMenuOpen = false"
155
206
  type="button"
@@ -157,6 +208,13 @@
157
208
  >
158
209
  Add Field
159
210
  </button>
211
+ <button
212
+ @click="copyDocument(); mobileMenuOpen = false"
213
+ type="button"
214
+ class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-blue-100"
215
+ >
216
+ Copy Document
217
+ </button>
160
218
  <button
161
219
  @click="shouldShowDeleteModal=true; mobileMenuOpen = false"
162
220
  :disabled="!canManipulate"
@@ -26,30 +26,25 @@ module.exports = app => app.component('document', {
26
26
  shouldShowConfirmModal: false,
27
27
  shouldShowDeleteModal: false,
28
28
  shouldShowCloneModal: false,
29
- previousQuery: null
29
+ previousQuery: null,
30
+ lastUpdatedAt: null,
31
+ isRefreshing: false,
32
+ autoRefreshEnabled: false,
33
+ autoRefreshConnecting: false,
34
+ autoRefreshLoopRunning: false,
35
+ autoRefreshError: null,
36
+ autoRefreshAbortController: null,
37
+ autoRefreshRetryTimer: null,
38
+ pendingRefresh: false
30
39
  }),
31
40
  async mounted() {
32
41
  window.pageState = this;
33
42
  // Store query parameters from the route (preserved from models page)
34
43
  this.previousQuery = Object.assign({}, this.$route.query);
35
- try {
36
- const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({ model: this.model, documentId: this.documentId });
37
- window.doc = doc;
38
- this.document = doc;
39
- this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
40
- if (k1 === '_id' && k2 !== '_id') {
41
- return -1;
42
- }
43
- if (k1 !== '_id' && k2 === '_id') {
44
- return 1;
45
- }
46
- return 0;
47
- }).map(key => schemaPaths[key]);
48
- this.virtualPaths = virtualPaths || [];
49
- this.status = 'loaded';
50
- } finally {
51
- this.status = 'loaded';
52
- }
44
+ await this.refreshDocument({ force: true, source: 'initial' });
45
+ },
46
+ beforeDestroy() {
47
+ this.stopAutoRefresh();
53
48
  },
54
49
  computed: {
55
50
  canManipulate() {
@@ -61,8 +56,32 @@ module.exports = app => app.component('document', {
61
56
  }
62
57
  return !this.roles.includes('readonly');
63
58
  },
59
+ lastUpdatedLabel() {
60
+ if (!this.lastUpdatedAt) {
61
+ return '—';
62
+ }
63
+ try {
64
+ return new Date(this.lastUpdatedAt).toLocaleTimeString([], {
65
+ hour: '2-digit',
66
+ minute: '2-digit',
67
+ second: '2-digit'
68
+ });
69
+ } catch (err) {
70
+ return '—';
71
+ }
72
+ },
64
73
  canEdit() {
65
74
  return this.canManipulate && this.viewMode === 'fields';
75
+ },
76
+ isLambda() {
77
+ return !!window?.MONGOOSE_STUDIO_CONFIG?.isLambda;
78
+ }
79
+ },
80
+ watch: {
81
+ editting(nextValue) {
82
+ if (!nextValue && this.pendingRefresh) {
83
+ this.refreshDocument({ source: 'pending' });
84
+ }
66
85
  }
67
86
  },
68
87
  methods: {
@@ -70,6 +89,125 @@ module.exports = app => app.component('document', {
70
89
  this.changes = {};
71
90
  this.editting = false;
72
91
  },
92
+ async refreshDocument(options = {}) {
93
+ const { force = false, source = 'manual' } = options;
94
+ if (this.editting && !force) {
95
+ this.pendingRefresh = true;
96
+ return;
97
+ }
98
+ if (this.isRefreshing) {
99
+ this.pendingRefresh = true;
100
+ return;
101
+ }
102
+
103
+ const isInitial = this.status === 'init';
104
+ if (isInitial) {
105
+ this.status = 'loading';
106
+ }
107
+ this.isRefreshing = true;
108
+ this.autoRefreshError = null;
109
+ try {
110
+ const { doc, schemaPaths, virtualPaths } = await api.Model.getDocument({
111
+ model: this.model,
112
+ documentId: this.documentId
113
+ });
114
+ window.doc = doc;
115
+ this.document = doc;
116
+ this.schemaPaths = Object.keys(schemaPaths).sort((k1, k2) => {
117
+ if (k1 === '_id' && k2 !== '_id') {
118
+ return -1;
119
+ }
120
+ if (k1 !== '_id' && k2 === '_id') {
121
+ return 1;
122
+ }
123
+ return 0;
124
+ }).map(key => schemaPaths[key]);
125
+ this.virtualPaths = virtualPaths || [];
126
+ this.lastUpdatedAt = new Date();
127
+ this.pendingRefresh = false;
128
+ } catch (err) {
129
+ console.error('Error refreshing document:', err);
130
+ if (this.$toast && source !== 'initial') {
131
+ this.$toast.error('Failed to refresh document');
132
+ }
133
+ } finally {
134
+ this.status = 'loaded';
135
+ this.isRefreshing = false;
136
+ if (this.pendingRefresh && !this.editting) {
137
+ this.pendingRefresh = false;
138
+ this.refreshDocument({ source: 'pending' });
139
+ }
140
+ }
141
+ },
142
+ toggleAutoRefresh() {
143
+ if (this.autoRefreshEnabled) {
144
+ this.stopAutoRefresh();
145
+ } else {
146
+ this.startAutoRefresh();
147
+ }
148
+ },
149
+ startAutoRefresh() {
150
+ if (this.autoRefreshEnabled) {
151
+ return;
152
+ }
153
+ this.autoRefreshEnabled = true;
154
+ this.autoRefreshError = null;
155
+ this.runAutoRefreshLoop();
156
+ },
157
+ stopAutoRefresh() {
158
+ this.autoRefreshEnabled = false;
159
+ this.autoRefreshConnecting = false;
160
+ if (this.autoRefreshAbortController) {
161
+ this.autoRefreshAbortController.abort();
162
+ this.autoRefreshAbortController = null;
163
+ }
164
+ if (this.autoRefreshRetryTimer) {
165
+ clearTimeout(this.autoRefreshRetryTimer);
166
+ this.autoRefreshRetryTimer = null;
167
+ }
168
+ },
169
+ async runAutoRefreshLoop() {
170
+ if (this.autoRefreshLoopRunning) {
171
+ return;
172
+ }
173
+ this.autoRefreshLoopRunning = true;
174
+ this.autoRefreshAbortController = new AbortController();
175
+ let retryDelay = 1500;
176
+
177
+ while (this.autoRefreshEnabled && !this.autoRefreshAbortController.signal.aborted) {
178
+ try {
179
+ this.autoRefreshConnecting = true;
180
+ for await (const event of api.Model.streamDocumentChanges({
181
+ model: this.model,
182
+ documentId: this.documentId
183
+ }, { signal: this.autoRefreshAbortController.signal })) {
184
+ this.autoRefreshConnecting = false;
185
+ if (!event || event.type === 'heartbeat') {
186
+ continue;
187
+ }
188
+ await this.refreshDocument({ source: 'auto' });
189
+ }
190
+ } catch (err) {
191
+ if (this.autoRefreshAbortController.signal.aborted) {
192
+ break;
193
+ }
194
+ this.autoRefreshError = err?.message || String(err);
195
+ } finally {
196
+ this.autoRefreshConnecting = false;
197
+ }
198
+
199
+ if (!this.autoRefreshEnabled || this.autoRefreshAbortController.signal.aborted) {
200
+ break;
201
+ }
202
+
203
+ await new Promise(resolve => {
204
+ this.autoRefreshRetryTimer = setTimeout(resolve, retryDelay);
205
+ });
206
+ retryDelay = Math.min(retryDelay * 2, 15000);
207
+ }
208
+
209
+ this.autoRefreshLoopRunning = false;
210
+ },
73
211
  async save() {
74
212
  if (Object.keys(this.invalid).length > 0) {
75
213
  throw new Error('Invalid paths: ' + Object.keys(this.invalid).join(', '));
@@ -137,6 +275,43 @@ module.exports = app => app.component('document', {
137
275
  this.changes = {};
138
276
  }
139
277
  },
278
+ copyDocument() {
279
+ if (!this.document) {
280
+ return;
281
+ }
282
+
283
+ const textToCopy = JSON.stringify(this.document, null, 2);
284
+ const fallbackCopy = () => {
285
+ if (typeof document === 'undefined') {
286
+ return;
287
+ }
288
+ const textArea = document.createElement('textarea');
289
+ textArea.value = textToCopy;
290
+ textArea.setAttribute('readonly', '');
291
+ textArea.style.position = 'absolute';
292
+ textArea.style.left = '-9999px';
293
+ document.body.appendChild(textArea);
294
+ textArea.select();
295
+ try {
296
+ document.execCommand('copy');
297
+ } finally {
298
+ document.body.removeChild(textArea);
299
+ }
300
+ this.$toast.success('Document copied!');
301
+ };
302
+
303
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
304
+ navigator.clipboard.writeText(textToCopy)
305
+ .then(() => {
306
+ this.$toast.success('Document copied!');
307
+ })
308
+ .catch(() => {
309
+ fallbackCopy();
310
+ });
311
+ } else {
312
+ fallbackCopy();
313
+ }
314
+ },
140
315
  goBack() {
141
316
  // Preserve query parameters when going back to models page
142
317
  this.$router.push({
@@ -130,8 +130,12 @@ app.component('app-component', {
130
130
  const { user, roles } = await mothership.me();
131
131
 
132
132
  try {
133
- const { nodeEnv } = await api.status();
133
+ const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
134
+ api.status(),
135
+ api.Model.listModels()
136
+ ]);
134
137
  this.nodeEnv = nodeEnv;
138
+ this.modelSchemaPaths = modelSchemaPaths;
135
139
  } catch (err) {
136
140
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
137
141
  this.status = 'loaded';
@@ -144,8 +148,12 @@ app.component('app-component', {
144
148
  }
145
149
  } else {
146
150
  try {
147
- const { nodeEnv } = await api.status();
151
+ const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
152
+ api.status(),
153
+ api.Model.listModels()
154
+ ]);
148
155
  this.nodeEnv = nodeEnv;
156
+ this.modelSchemaPaths = modelSchemaPaths;
149
157
  } catch (err) {
150
158
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
151
159
  }
@@ -159,8 +167,9 @@ app.component('app-component', {
159
167
  const status = Vue.ref('init');
160
168
  const nodeEnv = Vue.ref(null);
161
169
  const authError = Vue.ref(null);
170
+ const modelSchemaPaths = Vue.ref(null);
162
171
 
163
- const state = Vue.reactive({ user, roles, status, nodeEnv, authError });
172
+ const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths });
164
173
  Vue.provide('state', state);
165
174
 
166
175
  return state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mongoosejs/studio",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "A Mongoose-native MongoDB UI with schema-aware autocomplete, AI-assisted queries, and dashboards that understand your models - not just your data.",
5
5
  "homepage": "https://mongoosestudio.app/",
6
6
  "repository": {