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",
"stackedplot",
"Andale",
"unnnormalized",
"checksnapshots",
"specced"
],

View File

@ -354,6 +354,9 @@ export default class ObjectAPI {
isPersistable(idOrKeyString) {
let identifier = utils.parseKeyString(idOrKeyString);
let provider = this.getProvider(identifier);
if (provider?.isReadOnly) {
return !provider.isReadOnly();
}
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);
openmct.install(couchPlugin);
openmct.install(
new CouchDBSearchFolderPlugin('CouchDB Documents', couchPlugin, {
selector: {

View File

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

View File

@ -1,8 +1,26 @@
# My Items plugin
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.
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.
## Installation
```js
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.
*****************************************************************************/
import { MY_ITEMS_KEY } from './createMyItemsIdentifier.js';
function myItemsInterceptor(openmct, identifierObject, name) {
function myItemsInterceptor({ openmct, identifierObject, name }) {
const myItemsModel = {
identifier: identifierObject,
name,
@ -33,7 +31,10 @@ function myItemsInterceptor(openmct, identifierObject, name) {
return {
appliesTo: (identifier) => {
return identifier.key === MY_ITEMS_KEY;
return (
identifier.key === myItemsModel.identifier.key &&
identifier.namespace === myItemsModel.identifier.namespace
);
},
invoke: (identifier, object) => {
if (!object || openmct.objects.isMissing(object)) {

View File

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

View File

@ -80,7 +80,7 @@
keepAliveTimer = setTimeout(self.listenForChanges, keepAliveTime);
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.onerror = self.onerror;
couchEventSource.onopen = self.onopen;
@ -88,7 +88,7 @@
// start listening for events
couchEventSource.addEventListener('message', self.onCouchMessage);
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';
class CouchObjectProvider {
constructor(openmct, options, namespace, indicator) {
options = this.#normalize(options);
constructor({ openmct, databaseConfiguration, couchStatusIndicator }) {
this.openmct = openmct;
this.indicator = indicator;
this.url = options.url;
this.useDesignDocuments = options.useDesignDocuments;
this.namespace = namespace;
this.indicator = couchStatusIndicator;
this.url = databaseConfiguration.url;
this.readOnly = databaseConfiguration.readOnly;
this.useDesignDocuments = databaseConfiguration.useDesignDocuments;
this.namespace = databaseConfiguration.namespace;
this.objectQueue = {};
this.observers = {};
this.batchIds = [];
@ -47,6 +47,7 @@ class CouchObjectProvider {
this.onEventError = this.onEventError.bind(this);
this.flushPersistenceQueue = _.debounce(this.flushPersistenceQueue.bind(this));
this.persistenceQueue = [];
this.rootObject = null;
}
/**
@ -59,7 +60,10 @@ class CouchObjectProvider {
// eslint-disable-next-line no-undef
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.onmessageerror = provider.onSharedWorkerMessageError.bind(this);
sharedWorker.port.start();
@ -93,7 +97,7 @@ class CouchObjectProvider {
this.changesFeedSharedWorkerConnectionId = event.data.connectionId;
} else if (event.data.type === 'state') {
const state = this.#messageToIndicatorState(event.data.state);
this.indicator.setIndicatorToState(state);
this.indicator?.setIndicatorToState(state);
} else {
let objectChanges = event.data.objectChanges;
const objectIdentifier = {
@ -184,16 +188,8 @@ class CouchObjectProvider {
return state;
}
//backwards compatibility, options used to be a url. Now it's an object
#normalize(options) {
if (typeof options === 'string') {
return {
url: options,
useDesignDocuments: false
};
}
return options;
isReadOnly() {
return this.readOnly;
}
async request(subPath, method, body, signal) {
@ -233,7 +229,7 @@ class CouchObjectProvider {
// Network error, CouchDB unreachable.
if (response === null) {
this.indicator.setIndicatorToState(DISCONNECTED);
this.indicator?.setIndicatorToState(DISCONNECTED);
console.error(error.message);
throw new Error(`CouchDB Error - No response"`);
@ -256,7 +252,7 @@ class CouchObjectProvider {
* @private
*/
#handleResponseCode(status, json, fetchOptions) {
this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status));
this.indicator?.setIndicatorToState(this.#statusCodeToIndicatorState(status));
if (status === CouchObjectProvider.HTTP_CONFLICT) {
const objectName = JSON.parse(fetchOptions.body)?.model?.name;
throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`);
@ -684,7 +680,7 @@ class CouchObjectProvider {
}
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);
}
isReadOnly() {
return true;
}
search(query, abortSignal, searchType) {
if (searchType === this.searchTypes.OBJECTS) {
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:
```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
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>
4. Look at the 'JSON' tab and ensure you can see the specific object you created above.
5. All done! 🏆
@ -242,4 +292,4 @@ To enable them in Open MCT, we need to configure the plugin `useDesignDocuments`
```js
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 CouchStatusIndicator from './CouchStatusIndicator.js';
const NAMESPACE = '';
const DEFAULT_NAMESPACE = '';
const LEGACY_SPACE = 'mct';
const COUCH_SEARCH_ONLY_NAMESPACE = `COUCH_SEARCH_${Date.now()}`;
export default function CouchPlugin(options) {
return function install(openmct) {
const simpleIndicator = openmct.indicators.simpleIndicator();
openmct.indicators.add(simpleIndicator);
const couchStatusIndicator = new CouchStatusIndicator(simpleIndicator);
install.couchProvider = new CouchObjectProvider(
openmct,
options,
NAMESPACE,
couchStatusIndicator
);
function normalizeOptions(unnnormalizedOptions) {
const normalizedOptions = {};
if (typeof unnnormalizedOptions === 'string') {
normalizedOptions.databases = [
{
url: options,
namespace: DEFAULT_NAMESPACE,
additionalNamespaces: [LEGACY_SPACE],
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 "")
// Installing the same provider under both namespaces means that it can respond to object gets for both namespaces.
openmct.objects.addProvider(LEGACY_SPACE, install.couchProvider);
openmct.objects.addProvider(NAMESPACE, install.couchProvider);
openmct.objects.addProvider(
COUCH_SEARCH_ONLY_NAMESPACE,
new CouchSearchProvider(install.couchProvider)
);
// final sanity check, ensure we have all options
normalizedOptions.databases.forEach((databaseConfiguration) => {
if (!databaseConfiguration.url) {
throw new Error(
`🛑 CouchDB plugin requires a url option. Please check the configuration for namespace ${databaseConfiguration.namespace}`
);
} 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)
);
});
};
}