Add support for multiple CouchDB databases, multiple namespaces, and readOnly configurations (#7413)

* two namespaces appear

* works with two databases

* try to batch requests

* fix indicators

* add option to omit root for myitems

* ready for review

* ready for review

* ready for review

* typo in README

* spelling

* spelling

* update readme

* fix tests

* Update src/plugins/persistence/couch/README.md

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>

* remove omitRoot

* remove omitRoot

* fix my items to check for namespace when intercepting

* update readme

---------

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
Scott Bell 2024-01-31 00:10:31 +01:00 committed by GitHub
parent 69b81c00ca
commit d42aa545bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 184 additions and 56 deletions

View File

@ -493,6 +493,7 @@
"WCAG", "WCAG",
"stackedplot", "stackedplot",
"Andale", "Andale",
"unnnormalized",
"checksnapshots", "checksnapshots",
"specced" "specced"
], ],

View File

@ -354,6 +354,9 @@ export default class ObjectAPI {
isPersistable(idOrKeyString) { isPersistable(idOrKeyString) {
let identifier = utils.parseKeyString(idOrKeyString); let identifier = utils.parseKeyString(idOrKeyString);
let provider = this.getProvider(identifier); let provider = this.getProvider(identifier);
if (provider?.isReadOnly) {
return !provider.isReadOnly();
}
return provider !== undefined && provider.create !== undefined && provider.update !== undefined; return provider !== undefined && provider.create !== undefined && provider.update !== undefined;
} }

View File

@ -38,7 +38,6 @@ describe('the plugin', function () {
let couchPlugin = openmct.plugins.CouchDB(testPath); let couchPlugin = openmct.plugins.CouchDB(testPath);
openmct.install(couchPlugin); openmct.install(couchPlugin);
openmct.install( openmct.install(
new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, { new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, {
selector: { selector: {

View File

@ -74,6 +74,10 @@ export default class LocalStorageObjectProvider {
this.localStorage.setItem(this.spaceKey, JSON.stringify(space)); this.localStorage.setItem(this.spaceKey, JSON.stringify(space));
} }
isReadOnly() {
return false;
}
/** /**
* @private * @private
*/ */

View File

@ -1,8 +1,26 @@
# My Items plugin # My Items plugin
Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a Defines top-level folder named "My Items" to store user-created items. Enabled by default, this can be disabled in a read-only deployment with no user-editable objects.
read-only deployment with no user-editable objects.
## Installation ## Installation
```js ```js
openmct.install(openmct.plugins.MyItems()); openmct.install(openmct.plugins.MyItems());
```
## Options
When installing, the plugin can take several options:
- `name`: The label of the root object. Defaults to "My Items"
- Example: `'Apple Items'`
- `namespace`: The namespace to create the root object in. Defaults to the empty string `''`
- Example: `'apple-namespace'`
- `priority`: The optional priority to install this plugin. Defaults to `openmct.priority.LOW`
- Example: `'openmct.priority.LOW'`
E.g., to install with a custom name and namespace, you could use:
```js
openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace'));
``` ```

View File

@ -20,9 +20,7 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
import { MY_ITEMS_KEY } from './createMyItemsIdentifier.js'; function myItemsInterceptor({ openmct, identifierObject, name }) {
function myItemsInterceptor(openmct, identifierObject, name) {
const myItemsModel = { const myItemsModel = {
identifier: identifierObject, identifier: identifierObject,
name, name,
@ -33,7 +31,10 @@ function myItemsInterceptor(openmct, identifierObject, name) {
return { return {
appliesTo: (identifier) => { appliesTo: (identifier) => {
return identifier.key === MY_ITEMS_KEY; return (
identifier.key === myItemsModel.identifier.key &&
identifier.namespace === myItemsModel.identifier.namespace
);
}, },
invoke: (identifier, object) => { invoke: (identifier, object) => {
if (!object || openmct.objects.isMissing(object)) { if (!object || openmct.objects.isMissing(object)) {

View File

@ -31,13 +31,13 @@ export default function MyItemsPlugin(
priority = undefined priority = undefined
) { ) {
return function install(openmct) { return function install(openmct) {
const identifier = createMyItemsIdentifier(namespace); const identifierObject = createMyItemsIdentifier(namespace);
if (priority === undefined) { if (priority === undefined) {
priority = openmct.priority.LOW; priority = openmct.priority.LOW;
} }
openmct.objects.addGetInterceptor(myItemsInterceptor(openmct, identifier, name)); openmct.objects.addGetInterceptor(myItemsInterceptor({ openmct, identifierObject, name }));
openmct.objects.addRoot(identifier, priority); openmct.objects.addRoot(identifierObject, priority);
}; };
} }

View File

@ -80,7 +80,7 @@
keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime); keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime);
if (!couchEventSource || couchEventSource.readyState === EventSource.CLOSED) { if (!couchEventSource || couchEventSource.readyState === EventSource.CLOSED) {
console.debug('⇿ Opening CouchDB change feed connection ⇿'); console.debug(`⇿ Opening CouchDB change feed connection for ${changesFeedUrl}`);
couchEventSource = new EventSource(changesFeedUrl); couchEventSource = new EventSource(changesFeedUrl);
couchEventSource.onerror = self.onerror; couchEventSource.onerror = self.onerror;
couchEventSource.onopen = self.onopen; couchEventSource.onopen = self.onopen;
@ -88,7 +88,7 @@
// start listening for events // start listening for events
couchEventSource.addEventListener('message', self.onCouchMessage); couchEventSource.addEventListener('message', self.onCouchMessage);
connected = true; connected = true;
console.debug('⇿ Opened connection ⇿'); console.debug(`⇿ Opened connection to ${changesFeedUrl}`);
} }
}; };

View File

@ -33,13 +33,13 @@ const HEARTBEAT = 50000;
const ALL_DOCS = '_all_docs?include_docs=true'; const ALL_DOCS = '_all_docs?include_docs=true';
class CouchObjectProvider { class CouchObjectProvider {
constructor(openmct, options, namespace, indicator) { constructor({ openmct, databaseConfiguration, couchStatusIndicator }) {
options = this.#normalize(options);
this.openmct = openmct; this.openmct = openmct;
this.indicator = indicator; this.indicator = couchStatusIndicator;
this.url = options.url; this.url = databaseConfiguration.url;
this.useDesignDocuments = options.useDesignDocuments; this.readOnly = databaseConfiguration.readOnly;
this.namespace = namespace; this.useDesignDocuments = databaseConfiguration.useDesignDocuments;
this.namespace = databaseConfiguration.namespace;
this.objectQueue = {}; this.objectQueue = {};
this.observers = {}; this.observers = {};
this.batchIds = []; this.batchIds = [];
@ -47,6 +47,7 @@ class CouchObjectProvider {
this.onEventError = this.onEventError.bind(this); this.onEventError = this.onEventError.bind(this);
this.flushPersistenceQueue = _.debounce(this.flushPersistenceQueue.bind(this)); this.flushPersistenceQueue = _.debounce(this.flushPersistenceQueue.bind(this));
this.persistenceQueue = []; this.persistenceQueue = [];
this.rootObject = null;
} }
/** /**
@ -59,7 +60,10 @@ class CouchObjectProvider {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}couchDBChangesFeed.js`; const sharedWorkerURL = `${this.openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}couchDBChangesFeed.js`;
sharedWorker = new SharedWorker(sharedWorkerURL, 'CouchDB SSE Shared Worker'); sharedWorker = new SharedWorker(
sharedWorkerURL,
`CouchDB SSE Shared Worker for ${this.namespace}`
);
sharedWorker.port.onmessage = provider.onSharedWorkerMessage.bind(this); sharedWorker.port.onmessage = provider.onSharedWorkerMessage.bind(this);
sharedWorker.port.onmessageerror = provider.onSharedWorkerMessageError.bind(this); sharedWorker.port.onmessageerror = provider.onSharedWorkerMessageError.bind(this);
sharedWorker.port.start(); sharedWorker.port.start();
@ -93,7 +97,7 @@ class CouchObjectProvider {
this.changesFeedSharedWorkerConnectionId = event.data.connectionId; this.changesFeedSharedWorkerConnectionId = event.data.connectionId;
} else if (event.data.type === 'state') { } else if (event.data.type === 'state') {
const state = this.#messageToIndicatorState(event.data.state); const state = this.#messageToIndicatorState(event.data.state);
this.indicator.setIndicatorToState(state); this.indicator?.setIndicatorToState(state);
} else { } else {
let objectChanges = event.data.objectChanges; let objectChanges = event.data.objectChanges;
const objectIdentifier = { const objectIdentifier = {
@ -184,16 +188,8 @@ class CouchObjectProvider {
return state; return state;
} }
//backwards compatibility, options used to be a url. Now it's an object isReadOnly() {
#normalize(options) { return this.readOnly;
if (typeof options === 'string') {
return {
url: options,
useDesignDocuments: false
};
}
return options;
} }
async request(subPath, method, body, signal) { async request(subPath, method, body, signal) {
@ -233,7 +229,7 @@ class CouchObjectProvider {
// Network error, CouchDB unreachable. // Network error, CouchDB unreachable.
if (response === null) { if (response === null) {
this.indicator.setIndicatorToState(DISCONNECTED); this.indicator?.setIndicatorToState(DISCONNECTED);
console.error(error.message); console.error(error.message);
throw new Error(`CouchDB Error - No response"`); throw new Error(`CouchDB Error - No response"`);
@ -256,7 +252,7 @@ class CouchObjectProvider {
* @private * @private
*/ */
#handleResponseCode(status, json, fetchOptions) { #handleResponseCode(status, json, fetchOptions) {
this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status)); this.indicator?.setIndicatorToState(this.#statusCodeToIndicatorState(status));
if (status === CouchObjectProvider.HTTP_CONFLICT) { if (status === CouchObjectProvider.HTTP_CONFLICT) {
const objectName = JSON.parse(fetchOptions.body)?.model?.name; const objectName = JSON.parse(fetchOptions.body)?.model?.name;
throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`); throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`);
@ -684,7 +680,7 @@ class CouchObjectProvider {
} }
const indicatorState = this.#messageToIndicatorState(message); const indicatorState = this.#messageToIndicatorState(message);
this.indicator.setIndicatorToState(indicatorState); this.indicator?.setIndicatorToState(indicatorState);
} }
/** /**

View File

@ -51,6 +51,10 @@ class CouchSearchProvider {
return this.supportedSearchTypes.includes(searchType); return this.supportedSearchTypes.includes(searchType);
} }
isReadOnly() {
return true;
}
search(query, abortSignal, searchType) { search(query, abortSignal, searchType) {
if (searchType === this.searchTypes.OBJECTS) { if (searchType === this.searchTypes.OBJECTS) {
return this.searchForObjects(query, abortSignal); return this.searchForObjects(query, abortSignal);

View File

@ -153,13 +153,63 @@ sh ./src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.s
Add a line to install the CouchDB plugin for Open MCT: Add a line to install the CouchDB plugin for Open MCT:
```js ```js
openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: false})); openmct.install(
openmct.plugins.CouchDB({
databases: [
{
url: 'http://localhost:5984/openmct',
namespace: '',
additionalNamespaces: [],
readOnly: false,
useDesignDocuments: false,
indicator: true
}
]
})
);
``` ```
### Configuration Options for OpenMCT
When installing the CouchDB plugin for OpenMCT, you can specify a list of databases with configuration options for each. Here's a breakdown of the available options for each database:
- `url`: The URL to the CouchDB instance, specifying the protocol, hostname, and port as needed.
- Example: `'http://localhost:5984/openmct'`
- `namespace`: The namespace associated with this database.
- Example: `'openmct-sandbox'`
- `additionalNamespaces`: Other namespaces that this plugin should respond to requests for.
- Example: `['apple-namespace', 'pear-namespace']`
- `readOnly`: A boolean indicating whether the database should be treated as read-only. If set to `true`, OpenMCT will not attempt to write to this database.
- Example: `false`
- `useDesignDocuments`: Indicates whether design documents should be used to speed up annotation search.
- Example: `false`
- `indicator`: A boolean to specify whether an indicator should show the status of this CouchDB connection in the OpenMCT interface.
- Example: `true`
Note: If using the `exampleTags` plugin with non-blank namespaces, you'll need to configure it point to a writable database. For example:
```js
openmct.install(
openmct.plugins.example.ExampleTags({ namespaceToSaveAnnotations: 'openmct-sandbox' })
);
```
Note: If using the `MyItems` plugin, be sure to configure a root for each writable namespace. E.g., if you have two namespaces called `apple-namespace` and `pear-namespace`:
```js
openmct.install(openmct.plugins.MyItems('Apple Items', 'apple-namespace'));
openmct.install(openmct.plugins.MyItems('Pear Items', 'pear-namespace'));
```
This will create a root object with the id of `mine` in both namespaces upon load if not already created.
# Validating a successful Installation # Validating a successful Installation
1. Start Open MCT by running `npm start` in the `openmct` path. 1. Start Open MCT by running `npm start` in the `openmct` path.
2. Navigate to <http://localhost:8080/> and create a random object in Open MCT (e.g., a 'Clock') and save. You may get an error saying that the object failed to persist - this is a known error that you can ignore, and will only happen the first time you save - just try again. 2. Navigate to <http://localhost:8080/> and create a random object in Open MCT (e.g., a 'Clock') and save.
3. Navigate to: <http://127.0.0.1:5984/_utils/#database/openmct/_all_docs> 3. Navigate to: <http://127.0.0.1:5984/_utils/#database/openmct/_all_docs>
4. Look at the 'JSON' tab and ensure you can see the specific object you created above. 4. Look at the 'JSON' tab and ensure you can see the specific object you created above.
5. All done! 🏆 5. All done! 🏆
@ -242,4 +292,4 @@ To enable them in Open MCT, we need to configure the plugin `useDesignDocuments`
```js ```js
openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: true})); openmct.install(openmct.plugins.CouchDB({url: "http://localhost:5984/openmct", useDesignDocuments: true}));
``` ```

View File

@ -24,29 +24,81 @@ import CouchObjectProvider from './CouchObjectProvider.js';
import CouchSearchProvider from './CouchSearchProvider.js'; import CouchSearchProvider from './CouchSearchProvider.js';
import CouchStatusIndicator from './CouchStatusIndicator.js'; import CouchStatusIndicator from './CouchStatusIndicator.js';
const NAMESPACE = ''; const DEFAULT_NAMESPACE = '';
const LEGACY_SPACE = 'mct'; const LEGACY_SPACE = 'mct';
const COUCH_SEARCH_ONLY_NAMESPACE = `COUCH_SEARCH_${Date.now()}`;
export default function CouchPlugin(options) { export default function CouchPlugin(options) {
return function install(openmct) { function normalizeOptions(unnnormalizedOptions) {
const simpleIndicator = openmct.indicators.simpleIndicator(); const normalizedOptions = {};
openmct.indicators.add(simpleIndicator); if (typeof unnnormalizedOptions === 'string') {
const couchStatusIndicator = new CouchStatusIndicator(simpleIndicator); normalizedOptions.databases = [
install.couchProvider = new CouchObjectProvider( {
openmct, url: options,
options, namespace: DEFAULT_NAMESPACE,
NAMESPACE, additionalNamespaces: [LEGACY_SPACE],
couchStatusIndicator readOnly: false,
); useDesignDocuments: false,
indicator: true
}
];
} else if (!unnnormalizedOptions.databases) {
normalizedOptions.databases = [
{
url: unnnormalizedOptions.url,
namespace: DEFAULT_NAMESPACE,
additionalNamespaces: [LEGACY_SPACE],
readOnly: false,
useDesignDocuments: unnnormalizedOptions.useDesignDocuments,
indicator: true
}
];
} else {
normalizedOptions.databases = unnnormalizedOptions.databases;
}
// Unfortunately, for historical reasons, Couch DB produces objects with a mix of namespaces (alternately "mct", and "") // final sanity check, ensure we have all options
// Installing the same provider under both namespaces means that it can respond to object gets for both namespaces. normalizedOptions.databases.forEach((databaseConfiguration) => {
openmct.objects.addProvider(LEGACY_SPACE, install.couchProvider); if (!databaseConfiguration.url) {
openmct.objects.addProvider(NAMESPACE, install.couchProvider); throw new Error(
openmct.objects.addProvider( `🛑 CouchDB plugin requires a url option. Please check the configuration for namespace ${databaseConfiguration.namespace}`
COUCH_SEARCH_ONLY_NAMESPACE, );
new CouchSearchProvider(install.couchProvider) } else if (databaseConfiguration.namespace === undefined) {
); // note we can't check for just !databaseConfiguration.namespace because it could be an empty string
throw new Error(
`🛑 CouchDB plugin requires a namespace option. Please check the configuration for url ${databaseConfiguration.url}`
);
}
});
return normalizedOptions;
}
return function install(openmct) {
const normalizedOptions = normalizeOptions(options);
normalizedOptions.databases.forEach((databaseConfiguration) => {
let couchStatusIndicator;
if (databaseConfiguration.indicator) {
const simpleIndicator = openmct.indicators.simpleIndicator();
openmct.indicators.add(simpleIndicator);
couchStatusIndicator = new CouchStatusIndicator(simpleIndicator);
}
// the provider is added to the install function to expose couchProvider to unit tests
install.couchProvider = new CouchObjectProvider({
openmct,
databaseConfiguration,
couchStatusIndicator
});
openmct.objects.addProvider(databaseConfiguration.namespace, install.couchProvider);
databaseConfiguration.additionalNamespaces?.forEach((additionalNamespace) => {
openmct.objects.addProvider(additionalNamespace, install.couchProvider);
});
// need one search provider for whole couch database
const searchOnlyNamespace = `COUCH_SEARCH_${databaseConfiguration.namespace}${Date.now()}`;
openmct.objects.addProvider(
searchOnlyNamespace,
new CouchSearchProvider(install.couchProvider)
);
});
}; };
} }