diff --git a/package-lock.json b/package-lock.json
index 57cdb448135..66bc1623198 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44872,6 +44872,7 @@
"@mongodb-js/compass-logging": "^1.4.8",
"@mongodb-js/compass-telemetry": "^1.2.0",
"hadron-app-registry": "^9.2.7",
+ "lodash": "^4.17.21",
"mongodb-ns": "^2.4.2",
"react": "^17.0.2",
"react-redux": "^8.1.3",
@@ -56613,6 +56614,7 @@
"depcheck": "^1.4.1",
"eslint": "^7.25.0",
"hadron-app-registry": "^9.2.7",
+ "lodash": "^4.17.21",
"mocha": "^10.2.0",
"mongodb-ns": "^2.4.2",
"nyc": "^15.1.0",
diff --git a/packages/compass-global-writes/package.json b/packages/compass-global-writes/package.json
index ab2f3f13335..cd3b029ccaa 100644
--- a/packages/compass-global-writes/package.json
+++ b/packages/compass-global-writes/package.json
@@ -56,6 +56,7 @@
"@mongodb-js/compass-logging": "^1.4.8",
"@mongodb-js/compass-telemetry": "^1.2.0",
"hadron-app-registry": "^9.2.7",
+ "lodash": "^4.17.21",
"@mongodb-js/compass-field-store": "^9.18.1",
"mongodb-ns": "^2.4.2",
"react": "^17.0.2",
diff --git a/packages/compass-global-writes/src/components/index.spec.tsx b/packages/compass-global-writes/src/components/index.spec.tsx
index e264d94197d..4aff0336345 100644
--- a/packages/compass-global-writes/src/components/index.spec.tsx
+++ b/packages/compass-global-writes/src/components/index.spec.tsx
@@ -5,25 +5,25 @@ import { GlobalWrites } from './index';
import { renderWithStore } from './../../tests/create-store';
describe('Compass GlobalWrites Plugin', function () {
- it('renders plugin in NOT_READY state', function () {
- renderWithStore( );
+ it('renders plugin in NOT_READY state', async function () {
+ await renderWithStore( );
expect(screen.getByText(/loading/i)).to.exist;
});
- it('renders plugin in UNSHARDED state', function () {
- renderWithStore( );
+ it('renders plugin in UNSHARDED state', async function () {
+ await renderWithStore( );
expect(screen.getByTestId('shard-collection-button')).to.exist;
});
- it('renders plugin in SUBMITTING_FOR_SHARDING state', function () {
- renderWithStore(
+ it('renders plugin in SUBMITTING_FOR_SHARDING state', async function () {
+ await renderWithStore(
);
expect(screen.getByTestId('shard-collection-button')).to.exist;
});
- it('renders plugin in SHARDING state', function () {
- renderWithStore( );
+ it('renders plugin in SHARDING state', async function () {
+ await renderWithStore( );
expect(screen.getByText(/sharding your collection/i)).to.exist;
});
});
diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx
index ff0a5b02fa4..77aa286de46 100644
--- a/packages/compass-global-writes/src/components/index.tsx
+++ b/packages/compass-global-writes/src/components/index.tsx
@@ -10,6 +10,7 @@ import type { RootState, ShardingStatus } from '../store/reducer';
import { ShardingStatuses } from '../store/reducer';
import UnshardedState from './states/unsharded';
import ShardingState from './states/sharding';
+import ShardKeyCorrect from './states/shard-key-correct';
const containerStyles = css({
paddingLeft: spacing[400],
@@ -58,6 +59,13 @@ function ShardingStateView({
return ;
}
+ if (
+ shardingStatus === ShardingStatuses.SHARD_KEY_CORRECT ||
+ shardingStatus === ShardingStatuses.UNMANAGING_NAMESPACE
+ ) {
+ return ;
+ }
+
return null;
}
diff --git a/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx b/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx
new file mode 100644
index 00000000000..5aae046e76e
--- /dev/null
+++ b/packages/compass-global-writes/src/components/shard-zones-table.spec.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { expect } from 'chai';
+import { render, screen, within } from '@mongodb-js/testing-library-compass';
+import { ShardZonesTable } from './shard-zones-table';
+import { type ShardZoneData } from '../store/reducer';
+
+describe('Compass GlobalWrites Plugin', function () {
+ const shardZones: ShardZoneData[] = [
+ {
+ zoneId: '45893084',
+ country: 'Germany',
+ readableName: 'Germany',
+ isoCode: 'DE',
+ typeOneIsoCode: 'DE',
+ zoneName: 'EMEA',
+ zoneLocations: ['Frankfurt'],
+ },
+ {
+ zoneId: '43829408',
+ country: 'Germany',
+ readableName: 'Germany - Berlin',
+ isoCode: 'DE-BE',
+ typeOneIsoCode: 'DE',
+ zoneName: 'EMEA',
+ zoneLocations: ['Frankfurt'],
+ },
+ ];
+
+ it('renders the Location name & Zone for all items', function () {
+ render( );
+
+ const rows = screen.getAllByRole('row');
+ expect(rows).to.have.lengthOf(3); // 1 header, 2 items
+ expect(within(rows[1]).getByText('Germany (DE)')).to.be.visible;
+ expect(within(rows[1]).getByText('EMEA (Frankfurt)')).to.be.visible;
+ expect(within(rows[2]).getByText('Germany - Berlin (DE-BE)')).to.be.visible;
+ expect(within(rows[2]).getByText('EMEA (Frankfurt)')).to.be.visible;
+ });
+});
diff --git a/packages/compass-global-writes/src/components/shard-zones-table.tsx b/packages/compass-global-writes/src/components/shard-zones-table.tsx
new file mode 100644
index 00000000000..abc866c668d
--- /dev/null
+++ b/packages/compass-global-writes/src/components/shard-zones-table.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import {
+ Table,
+ TableBody,
+ TableHead,
+ HeaderRow,
+ HeaderCell,
+ Row,
+ Cell,
+ css,
+} from '@mongodb-js/compass-components';
+import type { ShardZoneData } from '../store/reducer';
+
+const containerStyles = css({
+ maxWidth: '700px',
+ height: '400px',
+});
+
+export function ShardZonesTable({
+ shardZones,
+}: {
+ shardZones: ShardZoneData[];
+}) {
+ return (
+ // TODO(COMPASS-8336):
+ // Add search
+ // group zones by ShardZoneData.typeOneIsoCode
+ // and display them in a single row that can be expanded
+
+
+
+ Location Name
+ Zone
+
+
+
+ {shardZones.map(
+ ({ readableName, zoneName, zoneLocations, isoCode }, index) => {
+ return (
+
+ |
+ {readableName} ({isoCode})
+ |
+
+ {zoneName} ({zoneLocations.join(', ')})
+ |
+
+ );
+ }
+ )}
+
+
+ );
+}
diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx
new file mode 100644
index 00000000000..86f938cb10a
--- /dev/null
+++ b/packages/compass-global-writes/src/components/states/shard-key-correct.spec.tsx
@@ -0,0 +1,135 @@
+import React from 'react';
+import { expect } from 'chai';
+import { screen, userEvent } from '@mongodb-js/testing-library-compass';
+import {
+ ShardKeyCorrect,
+ type ShardKeyCorrectProps,
+} from './shard-key-correct';
+import { type ShardZoneData } from '../../store/reducer';
+import Sinon from 'sinon';
+import { renderWithStore } from '../../../tests/create-store';
+import { type ConnectionInfo } from '@mongodb-js/compass-connections/provider';
+
+describe('Compass GlobalWrites Plugin', function () {
+ const shardZones: ShardZoneData[] = [
+ {
+ zoneId: '45893084',
+ country: 'Germany',
+ readableName: 'Germany',
+ isoCode: 'DE',
+ typeOneIsoCode: 'DE',
+ zoneName: 'EMEA',
+ zoneLocations: ['Frankfurt'],
+ },
+ ];
+
+ const baseProps: ShardKeyCorrectProps = {
+ shardZones,
+ namespace: 'db1.coll1',
+ shardKey: {
+ fields: [
+ { type: 'HASHED', name: 'location' },
+ { type: 'RANGE', name: 'secondary' },
+ ],
+ isUnique: false,
+ },
+ isUnmanagingNamespace: false,
+ onUnmanageNamespace: () => {},
+ };
+
+ function renderWithProps(
+ props?: Partial,
+ options?: Parameters[1]
+ ) {
+ return renderWithStore(
+ ,
+ options
+ );
+ }
+
+ it('Provides button to unmanage', async function () {
+ const onUnmanageNamespace = Sinon.spy();
+ await renderWithProps({ onUnmanageNamespace });
+
+ const btn = await screen.findByRole('button', {
+ name: /Unmanage collection/,
+ });
+ expect(btn).to.be.visible;
+
+ userEvent.click(btn);
+
+ expect(onUnmanageNamespace).to.have.been.calledOnce;
+ });
+
+ it('Unmanage btn is disabled when the action is in progress', async function () {
+ const onUnmanageNamespace = Sinon.spy();
+ await renderWithProps({ onUnmanageNamespace, isUnmanagingNamespace: true });
+
+ const btn = await screen.findByTestId(
+ 'shard-collection-button'
+ );
+ expect(btn).to.be.visible;
+ expect(btn.getAttribute('aria-disabled')).to.equal('true');
+
+ userEvent.click(btn);
+
+ expect(onUnmanageNamespace).not.to.have.been.called;
+ });
+
+ it('Provides link to Edit Configuration', async function () {
+ const connectionInfo = {
+ id: 'testConnection',
+ connectionOptions: {
+ connectionString: 'mongodb://test',
+ },
+ atlasMetadata: {
+ projectId: 'project1',
+ clusterName: 'myCluster',
+ } as ConnectionInfo['atlasMetadata'],
+ };
+ await renderWithProps(undefined, {
+ connectionInfo,
+ });
+
+ const link = await screen.findByRole('link', {
+ name: /Edit Configuration/,
+ });
+ const expectedHref = `/v2/${connectionInfo.atlasMetadata?.projectId}#/clusters/edit/${connectionInfo.atlasMetadata?.clusterName}`;
+
+ expect(link).to.be.visible;
+ expect(link).to.have.attribute('href', expectedHref);
+ });
+
+ it('Describes the shardKey', async function () {
+ await renderWithProps();
+
+ const title = await screen.findByTestId('shardkey-description-title');
+ expect(title).to.be.visible;
+ expect(title.textContent).to.equal(
+ `${baseProps.namespace} is configured with the following shard key:`
+ );
+ const list = await screen.findByTestId('shardkey-description-content');
+ expect(list).to.be.visible;
+ expect(list.textContent).to.contain(`"location", "secondary"`);
+ });
+
+ it('Contains sample codes', async function () {
+ await renderWithProps();
+
+ const findingDocumentsSample = await screen.findByTestId(
+ 'sample-finding-documents'
+ );
+ expect(findingDocumentsSample).to.be.visible;
+ expect(findingDocumentsSample.textContent).to.contain(
+ `use db1db["coll1"].find({"location": "US-NY", "secondary": ""})`
+ );
+
+ const insertingDocumentsSample = await screen.findByTestId(
+ 'sample-inserting-documents'
+ );
+ expect(insertingDocumentsSample).to.be.visible;
+ expect(insertingDocumentsSample.textContent).to.contain(
+ `use db1db["coll1"].insertOne({"location": "US-NY", "secondary": "",...})`
+ );
+ });
+});
diff --git a/packages/compass-global-writes/src/components/states/shard-key-correct.tsx b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx
new file mode 100644
index 00000000000..05a29add5a8
--- /dev/null
+++ b/packages/compass-global-writes/src/components/states/shard-key-correct.tsx
@@ -0,0 +1,210 @@
+import React, { useMemo } from 'react';
+import {
+ Banner,
+ BannerVariant,
+ Body,
+ css,
+ Link,
+ spacing,
+ Code,
+ Subtitle,
+ Label,
+ Button,
+} from '@mongodb-js/compass-components';
+import { connect } from 'react-redux';
+import {
+ ShardingStatuses,
+ unmanageNamespace,
+ type RootState,
+ type ShardKey,
+ type ShardZoneData,
+} from '../../store/reducer';
+import toNS from 'mongodb-ns';
+import { ShardZonesTable } from '../shard-zones-table';
+import { useConnectionInfo } from '@mongodb-js/compass-connections/provider';
+
+const nbsp = '\u00a0';
+
+const containerStyles = css({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing[400],
+ marginBottom: spacing[400],
+});
+
+const codeBlockContainerStyles = css({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing[100],
+ maxWidth: '700px',
+});
+
+const paragraphStyles = css({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacing[100],
+});
+
+export type ShardKeyCorrectProps = {
+ namespace: string;
+ shardKey?: ShardKey;
+ shardZones: ShardZoneData[];
+ isUnmanagingNamespace: boolean;
+ onUnmanageNamespace: () => void;
+};
+
+export function ShardKeyCorrect({
+ namespace,
+ shardKey,
+ shardZones,
+ isUnmanagingNamespace,
+ onUnmanageNamespace,
+}: ShardKeyCorrectProps) {
+ if (!shardKey) {
+ throw new Error('Shard key not found in ShardKeyCorrect');
+ }
+
+ const customShardKeyField = useMemo(() => {
+ return shardKey.fields[1].name;
+ }, [shardKey]);
+
+ const { atlasMetadata } = useConnectionInfo();
+
+ const sampleCodes = useMemo(() => {
+ const { collection, database } = toNS(namespace);
+ return {
+ findingDocuments: `use ${database}\ndb[${JSON.stringify(
+ collection
+ )}].find({"location": "US-NY", "${customShardKeyField}": ""})`,
+ insertingDocuments: `use ${database}\ndb[${JSON.stringify(
+ collection
+ )}].insertOne({"location": "US-NY", "${customShardKeyField}": "",...})`,
+ };
+ }, [namespace, customShardKeyField]);
+
+ return (
+
+
+
+ All documents in your collection should contain both the ‘location’
+ field (with a ISO country or subdivision code) and your{' '}
+ {customShardKeyField} field at insert time.
+
+ {nbsp}We have included a table for reference below.
+
+
+
+
+ {namespace} is configured with the following shard
+ key:
+
+
+ {shardKey.fields.map((field) => `"${field.name}"`).join(', ')}
+
+
+
+
Example commands
+
+
+ Start querying your database with some of the most{' '}
+
+ common commands
+ {' '}
+ for Global Writes.
+
+
+ Replace the text to perform operations on different documents. US-NY
+ is an ISO 3166 location code referring to New York, United States. You
+ can look up other ISO 3166 location codes below.
+
+
+
+
+ Finding documents
+
+ {sampleCodes.findingDocuments}
+
+
+
+
+ Inserting documents
+
+ {sampleCodes.insertingDocuments}
+
+
+
+
Location Codes
+
+
+ Each document’s first field should include an ISO 3166-1 Alpha-2 code
+ for the location it belongs to.
+
+
+ We also support ISO 3166-2 subdivision codes for countries containing
+ a cloud provider data center (both ISO 3166-1 and ISO 3166-2 codes may
+ be used for these countries). All valid country codes and the zones to
+ which they map are listed in the table below. Additionally, you can
+ view a list of all location codes{' '}
+ here.
+
+
+ {atlasMetadata?.projectId && atlasMetadata?.clusterName && (
+ <>
+ Locations’ zone mapping can be changed by navigating to this
+ clusters{' '}
+
+ Edit Configuration
+ {' '}
+ page and clicking the Configure Location Mappings’ link above the
+ map.
+ >
+ )}
+
+
+
+
+
+
Unmanage this collection
+
+ Documents belonging to this collection will no longer be distributed
+ across the shards of your global clusters.
+
+
+
+ Unmanage collection
+
+
+
+ );
+}
+
+export default connect(
+ (state: RootState) => ({
+ namespace: state.namespace,
+ shardKey: state.shardKey,
+ shardZones: state.shardZones,
+ isUnmanagingNamespace:
+ state.status === ShardingStatuses.UNMANAGING_NAMESPACE,
+ }),
+ {
+ onUnmanageNamespace: unmanageNamespace,
+ }
+)(ShardKeyCorrect);
diff --git a/packages/compass-global-writes/src/components/states/sharding.spec.tsx b/packages/compass-global-writes/src/components/states/sharding.spec.tsx
index beb6e372928..c3d8fb542be 100644
--- a/packages/compass-global-writes/src/components/states/sharding.spec.tsx
+++ b/packages/compass-global-writes/src/components/states/sharding.spec.tsx
@@ -11,8 +11,8 @@ function renderWithProps(
}
describe('Sharding', function () {
- it('renders the info banner', function () {
- renderWithProps();
+ it('renders the info banner', async function () {
+ await renderWithProps();
expect(screen.getByRole('alert')).to.exist;
});
});
diff --git a/packages/compass-global-writes/src/components/states/usharded.spec.tsx b/packages/compass-global-writes/src/components/states/usharded.spec.tsx
index a26d5b228de..74d57ccb925 100644
--- a/packages/compass-global-writes/src/components/states/usharded.spec.tsx
+++ b/packages/compass-global-writes/src/components/states/usharded.spec.tsx
@@ -34,21 +34,21 @@ function setShardingKeyFieldValue(value: string) {
}
describe('UnshardedState', function () {
- it('renders the warning banner', function () {
- renderWithProps();
+ it('renders the warning banner', async function () {
+ await renderWithProps();
expect(screen.getByRole('alert')).to.exist;
});
- it('renders the text to the user', function () {
- renderWithProps();
+ it('renders the text to the user', async function () {
+ await renderWithProps();
expect(screen.getByTestId('unsharded-text-description')).to.exist;
});
context('shard collection form', function () {
let onCreateShardKeySpy: sinon.SinonSpy;
- beforeEach(function () {
+ beforeEach(async function () {
onCreateShardKeySpy = sinon.spy();
- renderWithProps({ onCreateShardKey: onCreateShardKeySpy });
+ await renderWithProps({ onCreateShardKey: onCreateShardKeySpy });
});
it('renders location form field as disabled', function () {
diff --git a/packages/compass-global-writes/src/plugin-title.tsx b/packages/compass-global-writes/src/plugin-title.tsx
index e7fd917d6d2..0c484aecaac 100644
--- a/packages/compass-global-writes/src/plugin-title.tsx
+++ b/packages/compass-global-writes/src/plugin-title.tsx
@@ -72,7 +72,7 @@ const PluginTitle = ({ showWarning }: { showWarning: boolean }) => {
};
export const GlobalWritesTabTitle = connect(
- ({ isNamespaceSharded, status }: RootState) => ({
- showWarning: !isNamespaceSharded && status !== ShardingStatuses.NOT_READY,
+ ({ managedNamespace, status }: RootState) => ({
+ showWarning: !managedNamespace && status !== ShardingStatuses.NOT_READY,
})
)(PluginTitle);
diff --git a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts
index d548eae05c3..db41cb5f24f 100644
--- a/packages/compass-global-writes/src/services/atlas-global-writes-service.ts
+++ b/packages/compass-global-writes/src/services/atlas-global-writes-service.ts
@@ -1,8 +1,16 @@
import toNS from 'mongodb-ns';
+import keyBy from 'lodash/keyBy';
import type { AtlasService } from '@mongodb-js/atlas-service/provider';
import type { CreateShardKeyData } from '../store/reducer';
+import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider';
-type ZoneMapping = unknown;
+export type ShardZoneMapping = {
+ isoCode: string;
+ typeOneIsoCode: string;
+ zoneId: string;
+ country: string;
+ readableName: string;
+};
export type ManagedNamespace = {
db: string;
collection: string;
@@ -14,18 +22,42 @@ export type ManagedNamespace = {
};
type GeoShardingData = {
- customZoneMapping: Record;
+ customZoneMapping: Record;
managedNamespaces: ManagedNamespace[];
selfManagedSharding: boolean;
};
-type ClusterDetailsApiResponse = {
+type ReplicationItem = {
+ id: string;
+ regionConfigs: {
+ regionView: {
+ location: string;
+ };
+ }[];
+ zoneId: string;
+ zoneName: string;
+};
+export type ClusterDetailsApiResponse = {
geoSharding: GeoShardingData;
+ replicationSpecList: ReplicationItem[];
+};
+
+type AutomationAgentProcess = {
+ statusType: string;
+ workingOnShort: string;
+ errorText: string;
};
-type AtlasCluterInfo = {
- projectId: string;
- clusterName: string;
+export type AutomationAgentDeploymentStatusApiResponse = {
+ automationStatus: {
+ processes: AutomationAgentProcess[];
+ };
+};
+
+type AtlasShardKey = {
+ _id: string;
+ unique: boolean;
+ key: Record;
};
function assertDataIsClusterDetailsApiResponse(
@@ -41,15 +73,44 @@ function assertDataIsClusterDetailsApiResponse(
'Invalid cluster details API response geoSharding.customZoneMapping'
);
}
+ if (!Array.isArray(data?.replicationSpecList)) {
+ throw new Error('Invalid cluster details API response replicationSpecList');
+ }
+}
+
+function assertDataIsAutomationAgentDeploymentStatusApiResponse(
+ data: any
+): asserts data is AutomationAgentDeploymentStatusApiResponse {
+ if (!Array.isArray(data?.automationStatus?.processes)) {
+ throw new Error(
+ 'Invalid automation agent deployment status API response automationStatus.processes'
+ );
+ }
+}
+
+function assertDataIsShardZonesApiResponse(
+ data: any
+): asserts data is Record {
+ if (typeof data !== 'object') {
+ throw new Error('Invalid shard zones API response');
+ }
}
export class AtlasGlobalWritesService {
- constructor(private atlasService: AtlasService) {}
+ constructor(
+ private atlasService: AtlasService,
+ private connectionInfo: ConnectionInfoRef
+ ) {}
+
+ private getAtlasMetadata() {
+ if (!this.connectionInfo.current?.atlasMetadata) {
+ throw new Error('Atlas metadata is not available');
+ }
+ return this.connectionInfo.current.atlasMetadata;
+ }
- private async fetchClusterDetails({
- clusterName,
- projectId,
- }: AtlasCluterInfo): Promise {
+ private async getClusterDetails(): Promise {
+ const { projectId, clusterName } = this.getAtlasMetadata();
const uri = this.atlasService.cloudEndpoint(
`nds/clusters/${projectId}/${clusterName}`
);
@@ -59,13 +120,10 @@ export class AtlasGlobalWritesService {
return clusterDetails;
}
- async isNamespaceManaged(
- namespace: string,
- atlasClusterInfo: AtlasCluterInfo
- ) {
- const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo);
+ async getManagedNamespace(namespace: string) {
+ const clusterDetails = await this.getClusterDetails();
const { database, collection } = toNS(namespace);
- return clusterDetails.geoSharding.managedNamespaces.some(
+ return clusterDetails.geoSharding.managedNamespaces.find(
(managedNamespace) => {
return (
managedNamespace.db === database &&
@@ -75,12 +133,8 @@ export class AtlasGlobalWritesService {
);
}
- async createShardKey(
- namespace: string,
- keyData: CreateShardKeyData,
- atlasClusterInfo: AtlasCluterInfo
- ) {
- const clusterDetails = await this.fetchClusterDetails(atlasClusterInfo);
+ async createShardKey(namespace: string, keyData: CreateShardKeyData) {
+ const clusterDetails = await this.getClusterDetails();
const { database, collection } = toNS(namespace);
const requestData: GeoShardingData = {
...clusterDetails.geoSharding,
@@ -94,8 +148,134 @@ export class AtlasGlobalWritesService {
],
};
+ const { projectId, clusterName } = this.getAtlasMetadata();
const uri = this.atlasService.cloudEndpoint(
- `nds/clusters/${atlasClusterInfo.projectId}/${atlasClusterInfo.clusterName}/geoSharding`
+ `nds/clusters/${projectId}/${clusterName}/geoSharding`
+ );
+
+ const response = await this.atlasService.authenticatedFetch(uri, {
+ method: 'PATCH',
+ body: JSON.stringify(requestData),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ assertDataIsClusterDetailsApiResponse(await response.json());
+
+ const managedNamespace = requestData.managedNamespaces.find(
+ (managedNamespace) =>
+ managedNamespace.db === database &&
+ managedNamespace.collection === collection
+ );
+ if (!managedNamespace) {
+ throw new Error('Managed namespace not found');
+ }
+ return managedNamespace;
+ }
+
+ async getShardingError(namespace: string) {
+ const { projectId } = this.getAtlasMetadata();
+ const uri = this.atlasService.cloudEndpoint(
+ `/automation/deploymentStatus/${projectId}`
+ );
+ const response = await this.atlasService.authenticatedFetch(uri);
+ const data = await response.json();
+ assertDataIsAutomationAgentDeploymentStatusApiResponse(data);
+ const namespaceShardingError = data.automationStatus.processes.find(
+ (process) =>
+ process.statusType === 'ERROR' &&
+ process.workingOnShort === 'ShardingCollections' &&
+ process.errorText.indexOf(namespace) !== -1
+ );
+ return namespaceShardingError?.errorText;
+ }
+
+ async getShardingKeys(namespace: string) {
+ const { database: db, collection } = toNS(namespace);
+ const atlasMetadata = this.getAtlasMetadata();
+
+ const req = await this.atlasService.automationAgentRequest(
+ atlasMetadata,
+ 'getShardKey',
+ {
+ db,
+ collection,
+ }
+ );
+
+ if (!req) {
+ throw new Error(
+ 'Unexpected response from the automation agent backend: expected to get the request metadata, got undefined'
+ );
+ }
+
+ const res = await this.atlasService.automationAgentAwait(
+ atlasMetadata,
+ req.requestType,
+ req._id
+ );
+ const data = res.response;
+
+ if (data.length === 0) {
+ return null;
+ }
+ const { key, unique } = data[0];
+
+ return {
+ fields: Object.keys(key).map(
+ (field) =>
+ ({
+ name: field,
+ type: key[field] === 'hashed' ? 'HASHED' : 'RANGE',
+ } as const)
+ ),
+ isUnique: !!unique,
+ };
+ }
+
+ async getShardingZones() {
+ const { projectId } = this.getAtlasMetadata();
+ const {
+ replicationSpecList: replicationSpecs,
+ geoSharding: { customZoneMapping },
+ } = await this.getClusterDetails();
+
+ const uri = this.atlasService.cloudEndpoint(
+ `/nds/geoSharding/${projectId}/newFormLocationMapping`
+ );
+ const response = await this.atlasService.authenticatedFetch(uri, {
+ method: 'POST',
+ body: JSON.stringify({
+ replicationSpecs,
+ customZoneMapping,
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ const data = await response.json();
+ assertDataIsShardZonesApiResponse(data);
+ return transformZoneData(Object.values(data), replicationSpecs);
+ }
+
+ async unmanageNamespace(namespace: string) {
+ const clusterDetails = await this.getClusterDetails();
+ const { database, collection } = toNS(namespace);
+
+ const newManagedNamespaces =
+ clusterDetails.geoSharding.managedNamespaces.filter(
+ (managedNamespace) =>
+ managedNamespace.db !== database ||
+ managedNamespace.collection !== collection
+ );
+ const requestData: GeoShardingData = {
+ ...clusterDetails.geoSharding,
+ managedNamespaces: newManagedNamespaces,
+ };
+
+ const { projectId, clusterName } = this.getAtlasMetadata();
+ const uri = this.atlasService.cloudEndpoint(
+ `nds/clusters/${projectId}/${clusterName}/geoSharding`
);
await this.atlasService.authenticatedFetch(uri, {
@@ -107,3 +287,21 @@ export class AtlasGlobalWritesService {
});
}
}
+
+function transformZoneData(
+ zoneData: ShardZoneMapping[],
+ replicationSpecs: ReplicationItem[]
+) {
+ const replicationSpecsMap = keyBy(replicationSpecs, 'zoneId');
+ return zoneData.map((zone) => ({
+ zoneId: zone.zoneId,
+ country: zone.country,
+ readableName: zone.readableName,
+ isoCode: zone.isoCode,
+ typeOneIsoCode: zone.typeOneIsoCode,
+ zoneName: replicationSpecsMap[zone.zoneId].zoneName,
+ zoneLocations: replicationSpecsMap[zone.zoneId].regionConfigs.map(
+ (regionConfig) => regionConfig.regionView.location
+ ),
+ }));
+}
diff --git a/packages/compass-global-writes/src/store/index.spec.ts b/packages/compass-global-writes/src/store/index.spec.ts
index 643869dc71b..7aa4c56e6d6 100644
--- a/packages/compass-global-writes/src/store/index.spec.ts
+++ b/packages/compass-global-writes/src/store/index.spec.ts
@@ -7,12 +7,51 @@ import {
type CreateShardKeyData,
} from './reducer';
import sinon from 'sinon';
+import type {
+ AutomationAgentDeploymentStatusApiResponse,
+ ClusterDetailsApiResponse,
+ ManagedNamespace,
+ ShardZoneMapping,
+} from '../services/atlas-global-writes-service';
+import { waitFor } from '@mongodb-js/testing-library-compass';
const DB = 'test';
const COLL = 'coll';
const NS = `${DB}.${COLL}`;
-function createJsonResponse(data: any) {
+const clusterDetails: ClusterDetailsApiResponse = {
+ geoSharding: {
+ customZoneMapping: {},
+ managedNamespaces: [],
+ selfManagedSharding: false,
+ },
+ replicationSpecList: [],
+};
+
+const managedNamespace: ManagedNamespace = {
+ db: DB,
+ collection: COLL,
+ customShardKey: 'secondary',
+ isCustomShardKeyHashed: false,
+ isShardKeyUnique: false,
+ numInitialChunks: null,
+ presplitHashedZones: false,
+};
+
+const shardKeyData: CreateShardKeyData = {
+ customShardKey: 'test',
+ isCustomShardKeyHashed: true,
+ isShardKeyUnique: false,
+ numInitialChunks: 1,
+ presplitHashedZones: true,
+};
+
+function createAuthFetchResponse<
+ TResponse extends
+ | ClusterDetailsApiResponse
+ | AutomationAgentDeploymentStatusApiResponse
+ | Record
+>(data: TResponse) {
return {
json: () => Promise.resolve(data),
};
@@ -36,108 +75,141 @@ describe('GlobalWritesStore Store', function () {
expect(store.getState().status).to.equal('NOT_READY');
});
- context('actions', function () {
- context('fetchClusterShardingData', function () {
- it('when the namespace is not managed', async function () {
- const store = createStore({
- authenticatedFetch: () =>
- createJsonResponse({
- geoSharding: { customZoneMapping: {}, managedNamespaces: [] },
- }),
- });
- await store.dispatch(fetchClusterShardingData());
- expect(store.getState().status).to.equal('UNSHARDED');
- expect(store.getState().isNamespaceSharded).to.equal(false);
+ context('scenarios', function () {
+ it('not managed -> sharding', async function () {
+ const store = createStore({
+ authenticatedFetch: () => createAuthFetchResponse(clusterDetails),
});
-
- // TODO (COMPASS-8277): Add more test for fetching shard key and process errors
+ await store.dispatch(fetchClusterShardingData());
+ expect(store.getState().status).to.equal('UNSHARDED');
+ expect(store.getState().managedNamespace).to.equal(undefined);
+
+ const promise = store.dispatch(createShardKey(shardKeyData));
+ expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING');
+ await promise;
+ expect(store.getState().status).to.equal('SHARDING');
});
- context('createShardKey', function () {
- const shardKeyData: CreateShardKeyData = {
- customShardKey: 'test',
- isCustomShardKeyHashed: true,
- isShardKeyUnique: false,
- numInitialChunks: 1,
- presplitHashedZones: true,
- };
-
- it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to SHARDING on success', async function () {
- const store = createStore({
- authenticatedFetch: () =>
- createJsonResponse({
- geoSharding: { customZoneMapping: {}, managedNamespaces: [] },
- }),
- });
-
- const promise = store.dispatch(createShardKey(shardKeyData));
- expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING');
-
- await promise;
- expect(store.getState().status).to.equal('SHARDING');
+ it('not managed -> failed sharding attempt', async function () {
+ const store = createStore({
+ authenticatedFetch: (uri: string) => {
+ if (uri.includes('/geoSharding')) {
+ return Promise.reject(new Error('Failed to shard'));
+ }
+
+ return createAuthFetchResponse(clusterDetails);
+ },
});
+ await store.dispatch(fetchClusterShardingData());
+ expect(store.getState().status).to.equal('UNSHARDED');
+ expect(store.getState().managedNamespace).to.equal(undefined);
+
+ const promise = store.dispatch(createShardKey(shardKeyData));
+ expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING');
+ await promise;
+ expect(store.getState().status).to.equal('UNSHARDED');
+ });
- it('sets SUBMITTING_FOR_SHARDING state when starting to create shard key and sets to UNSHARDED on failure', async function () {
- const store = createStore({
- authenticatedFetch: () => Promise.reject(new Error('error')),
- });
+ it('when the namespace is managed', async function () {
+ const store = createStore({
+ authenticatedFetch: (uri: string) => {
+ if (uri.includes('/clusters/')) {
+ return createAuthFetchResponse({
+ ...clusterDetails,
+ geoSharding: {
+ ...clusterDetails.geoSharding,
+ managedNamespaces: [managedNamespace],
+ },
+ });
+ }
+
+ if (uri.includes('/deploymentStatus/')) {
+ return createAuthFetchResponse({
+ automationStatus: {
+ processes: [],
+ },
+ });
+ }
+
+ return createAuthFetchResponse({});
+ },
+ automationAgentRequest: (_meta: unknown, type: string) => ({
+ _id: '123',
+ requestType: type,
+ }),
+ automationAgentAwait: (_meta: unknown, type: string) => {
+ if (type === 'getShardKey') {
+ return {
+ response: [
+ {
+ key: {
+ location: 'HASHED',
+ secondary: 'HASHED',
+ },
+ unique: false,
+ },
+ ],
+ };
+ }
+ },
+ });
+ await store.dispatch(fetchClusterShardingData());
+ await waitFor(() => {
+ expect(store.getState().status).to.equal('SHARD_KEY_CORRECT');
+ expect(store.getState().managedNamespace).to.equal(managedNamespace);
+ });
+ });
- const promise = store.dispatch(createShardKey(shardKeyData));
- expect(store.getState().status).to.equal('SUBMITTING_FOR_SHARDING');
+ it('sends correct data to the server when creating a shard key', async function () {
+ const alreadyManagedNamespaces = [
+ {
+ db: 'test',
+ collection: 'one',
+ customShardKey: 'test',
+ isCustomShardKeyHashed: true,
+ isShardKeyUnique: false,
+ numInitialChunks: 1,
+ presplitHashedZones: true,
+ },
+ ];
+
+ const getClusterInfoApiResponse = createAuthFetchResponse({
+ ...clusterDetails,
+ geoSharding: {
+ ...clusterDetails.geoSharding,
+ managedNamespaces: alreadyManagedNamespaces,
+ },
+ });
- await promise;
- expect(store.getState().status).to.equal('UNSHARDED');
+ // We call cluster API when store is activated to get the initial state.
+ // When creating a shard key, we call the same API to fetch the latest list of
+ // managed namespaces & then send it to the server along with the shard key data.
+ // So, we mock first and second call with same data. And then third call
+ // should be to create the shard key.
+ const fetchStub = sinon
+ .stub()
+ .onFirstCall()
+ .returns(getClusterInfoApiResponse)
+ .onSecondCall()
+ .returns(getClusterInfoApiResponse)
+ .onThirdCall()
+ .resolves();
+
+ const store = createStore({
+ authenticatedFetch: fetchStub,
});
- it('sends correct data to the server when creating a shard key', async function () {
- const alreadyManagedNamespaces = [
- {
- db: 'test',
- collection: 'one',
- customShardKey: 'test',
- isCustomShardKeyHashed: true,
- isShardKeyUnique: false,
- numInitialChunks: 1,
- presplitHashedZones: true,
- },
- ];
-
- const getClusterInfoApiResponse = createJsonResponse({
- geoSharding: {
- customZoneMapping: {},
- managedNamespaces: alreadyManagedNamespaces,
- },
- });
-
- // We call cluster API when store is activated to get the initial state.
- // When creating a shard key, we call the same API to fetch the latest list of
- // managed namespaces & then send it to the server along with the shard key data.
- // So, we mock first and second call with same data. And then third call
- // should be to create the shard key.
- const fetchStub = sinon
- .stub()
- .onFirstCall()
- .returns(getClusterInfoApiResponse)
- .onSecondCall()
- .returns(getClusterInfoApiResponse)
- .onThirdCall()
- .resolves();
-
- const store = createStore({
- authenticatedFetch: fetchStub,
- });
-
- await store.dispatch(createShardKey(shardKeyData));
-
- const options = fetchStub.getCall(2).args[1];
- expect(options.method).to.equal('PATCH');
- expect(JSON.parse(options.body)).to.deep.equal({
- customZoneMapping: {},
- managedNamespaces: [
- ...alreadyManagedNamespaces,
- { ...shardKeyData, db: DB, collection: COLL },
- ],
- });
+ await store.dispatch(createShardKey(shardKeyData));
+
+ const options = fetchStub.getCall(2).args[1];
+ expect(options.method).to.equal('PATCH');
+ expect(JSON.parse(options.body)).to.deep.equal({
+ customZoneMapping: {},
+ managedNamespaces: [
+ ...alreadyManagedNamespaces,
+ { ...shardKeyData, db: DB, collection: COLL },
+ ],
+ selfManagedSharding: false,
});
});
});
diff --git a/packages/compass-global-writes/src/store/index.ts b/packages/compass-global-writes/src/store/index.ts
index 334034a52a6..b00fad27e27 100644
--- a/packages/compass-global-writes/src/store/index.ts
+++ b/packages/compass-global-writes/src/store/index.ts
@@ -56,13 +56,16 @@ export function activateGlobalWritesPlugin(
}: GlobalWritesPluginServices,
{ cleanup }: ActivateHelpers
) {
- const atlasGlobalWritesService = new AtlasGlobalWritesService(atlasService);
+ const atlasGlobalWritesService = new AtlasGlobalWritesService(
+ atlasService,
+ connectionInfoRef
+ );
const store: GlobalWritesStore = createStore(
reducer,
{
namespace: options.namespace,
- isNamespaceSharded: false,
status: ShardingStatuses.NOT_READY,
+ shardZones: [],
},
applyMiddleware(
thunk.withExtraArgument({
diff --git a/packages/compass-global-writes/src/store/reducer.ts b/packages/compass-global-writes/src/store/reducer.ts
index 17e1dba65db..85981a039df 100644
--- a/packages/compass-global-writes/src/store/reducer.ts
+++ b/packages/compass-global-writes/src/store/reducer.ts
@@ -20,15 +20,39 @@ export type CreateShardKeyData = Pick<
>;
enum GlobalWritesActionTypes {
- IsManagedNamespaceFetched = 'global-writes/IsManagedNamespaceFetched',
+ ManagedNamespaceFetched = 'global-writes/ManagedNamespaceFetched',
+ NamespaceShardingErrorFetched = 'global-writes/NamespaceShardingErrorFetched',
+ NamespaceShardKeyFetched = 'global-writes/NamespaceShardKeyFetched',
+
+ ShardZonesFetched = 'global-writes/ShardZonesFetched',
+
SubmittingForShardingStarted = 'global-writes/SubmittingForShardingStarted',
SubmittingForShardingFinished = 'global-writes/SubmittingForShardingFinished',
SubmittingForShardingErrored = 'global-writes/SubmittingForShardingErrored',
+
+ UnmanagingNamespaceStarted = 'global-writes/UnmanagingNamespaceStarted',
+ UnmanagingNamespaceFinished = 'global-writes/UnmanagingNamespaceFinished',
+ UnmanagingNamespaceErrored = 'global-writes/UnmanagingNamespaceErrored',
}
-type IsManagedNamespaceFetchedAction = {
- type: GlobalWritesActionTypes.IsManagedNamespaceFetched;
- isNamespaceManaged: boolean;
+type ManagedNamespaceFetchedAction = {
+ type: GlobalWritesActionTypes.ManagedNamespaceFetched;
+ managedNamespace?: ManagedNamespace;
+};
+
+type NamespaceShardingErrorFetchedAction = {
+ type: GlobalWritesActionTypes.NamespaceShardingErrorFetched;
+ error: string;
+};
+
+type NamespaceShardKeyFetchedAction = {
+ type: GlobalWritesActionTypes.NamespaceShardKeyFetched;
+ shardKey: ShardKey;
+};
+
+type ShardZonesFetchedAction = {
+ type: GlobalWritesActionTypes.ShardZonesFetched;
+ shardZones: ShardZoneData[];
};
type SubmittingForShardingStartedAction = {
@@ -37,12 +61,25 @@ type SubmittingForShardingStartedAction = {
type SubmittingForShardingFinishedAction = {
type: GlobalWritesActionTypes.SubmittingForShardingFinished;
+ managedNamespace?: ManagedNamespace;
};
type SubmittingForShardingErroredAction = {
type: GlobalWritesActionTypes.SubmittingForShardingErrored;
};
+type UnmanagingNamespaceStartedAction = {
+ type: GlobalWritesActionTypes.UnmanagingNamespaceStarted;
+};
+
+type UnmanagingNamespaceFinishedAction = {
+ type: GlobalWritesActionTypes.UnmanagingNamespaceFinished;
+};
+
+type UnmanagingNamespaceErroredAction = {
+ type: GlobalWritesActionTypes.UnmanagingNamespaceErrored;
+};
+
export enum ShardingStatuses {
/**
* Initial status, no information available yet.
@@ -64,43 +101,162 @@ export enum ShardingStatuses {
* Namespace is being sharded.
*/
SHARDING = 'SHARDING',
+
+ /**
+ * Sharding failed.
+ */
+ SHARDING_ERROR = 'SHARDING_ERROR',
+
+ /**
+ * If the first key is not valid location key or the key is not compound.
+ */
+ SHARD_KEY_INVALID = 'SHARD_KEY_INVALID',
+
+ /**
+ * If the first key is valid (location key) and second key is not valid.
+ * The second key valid means that it matches with the managedNamespace's
+ * customShardKey and is of the correct type.
+ */
+ SHARD_KEY_MISMATCH = 'SHARD_KEY_MISMATCH',
+
+ /**
+ * Namespace is geo-sharded. Both, first key is valid
+ * location key and second key is valid custom key.
+ */
+ SHARD_KEY_CORRECT = 'SHARD_KEY_CORRECT',
+
+ /**
+ * Namespace is being unmanaged.
+ */
+ UNMANAGING_NAMESPACE = 'UNMANAGING_NAMESPACE',
}
export type ShardingStatus = keyof typeof ShardingStatuses;
-
+export type ShardKey = {
+ fields: Array<{
+ type: 'HASHED' | 'RANGE';
+ name: string;
+ }>;
+ isUnique: boolean;
+};
+export type ShardZoneData = {
+ zoneId: string;
+ country: string;
+ readableName: string;
+ isoCode: string;
+ typeOneIsoCode: string;
+ zoneName: string;
+ zoneLocations: string[];
+};
export type RootState = {
namespace: string;
- isNamespaceSharded: boolean;
- status: ShardingStatus;
-};
+ managedNamespace?: ManagedNamespace;
+ shardZones: ShardZoneData[];
+} & (
+ | {
+ status: ShardingStatuses.NOT_READY;
+ shardKey?: never;
+ shardingError?: never;
+ }
+ | {
+ status:
+ | ShardingStatuses.UNSHARDED
+ | ShardingStatuses.SUBMITTING_FOR_SHARDING
+ | ShardingStatuses.SHARDING;
+ /**
+ * note: shardKey might exist even for unsharded.
+ * if the collection was sharded previously and then unmanaged
+ */
+ shardKey?: ShardKey;
+ shardingError?: never;
+ }
+ | {
+ status: ShardingStatuses.SHARDING_ERROR;
+ shardKey?: never;
+ shardingError: string;
+ }
+ | {
+ status:
+ | ShardingStatuses.SHARD_KEY_CORRECT
+ | ShardingStatuses.SHARD_KEY_INVALID
+ | ShardingStatuses.SHARD_KEY_MISMATCH
+ | ShardingStatuses.UNMANAGING_NAMESPACE;
+ shardKey: ShardKey;
+ shardingError?: never;
+ }
+);
const initialState: RootState = {
namespace: '',
- isNamespaceSharded: false,
status: ShardingStatuses.NOT_READY,
+ shardZones: [],
};
const reducer: Reducer = (state = initialState, action) => {
if (
- isAction(
+ isAction(
action,
- GlobalWritesActionTypes.IsManagedNamespaceFetched
- )
+ GlobalWritesActionTypes.ManagedNamespaceFetched
+ ) &&
+ state.status === ShardingStatuses.NOT_READY
) {
return {
...state,
- isNamespaceSharded: action.isNamespaceManaged,
- status: !action.isNamespaceManaged
+ managedNamespace: action.managedNamespace,
+ status: !action.managedNamespace
? ShardingStatuses.UNSHARDED
: state.status,
};
}
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.NamespaceShardingErrorFetched
+ ) &&
+ state.status === ShardingStatuses.NOT_READY
+ ) {
+ return {
+ ...state,
+ status: ShardingStatuses.SHARDING_ERROR,
+ shardKey: undefined,
+ shardingError: action.error,
+ };
+ }
+
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.NamespaceShardKeyFetched
+ ) &&
+ state.status === ShardingStatuses.NOT_READY
+ ) {
+ return {
+ ...state,
+ status: getStatusFromShardKey(action.shardKey, state.managedNamespace),
+ shardKey: action.shardKey,
+ shardingError: undefined,
+ };
+ }
+
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.ShardZonesFetched
+ )
+ ) {
+ return {
+ ...state,
+ shardZones: action.shardZones,
+ };
+ }
+
if (
isAction(
action,
GlobalWritesActionTypes.SubmittingForShardingStarted
- )
+ ) &&
+ state.status === ShardingStatuses.UNSHARDED
) {
return {
...state,
@@ -112,11 +268,12 @@ const reducer: Reducer = (state = initialState, action) => {
isAction(
action,
GlobalWritesActionTypes.SubmittingForShardingFinished
- )
+ ) &&
+ state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING
) {
return {
...state,
- isNamespaceSharded: true,
+ managedNamespace: action.managedNamespace || state.managedNamespace,
status: ShardingStatuses.SHARDING,
};
}
@@ -125,49 +282,86 @@ const reducer: Reducer = (state = initialState, action) => {
isAction(
action,
GlobalWritesActionTypes.SubmittingForShardingErrored
- )
+ ) &&
+ state.status === ShardingStatuses.SUBMITTING_FOR_SHARDING
+ ) {
+ return {
+ ...state,
+ managedNamespace: undefined,
+ status: ShardingStatuses.UNSHARDED,
+ };
+ }
+
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.UnmanagingNamespaceStarted
+ ) &&
+ (state.status === ShardingStatuses.SHARD_KEY_CORRECT ||
+ state.status === ShardingStatuses.SHARD_KEY_INVALID ||
+ state.status === ShardingStatuses.SHARD_KEY_MISMATCH)
) {
return {
...state,
+ status: ShardingStatuses.UNMANAGING_NAMESPACE,
+ };
+ }
+
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.UnmanagingNamespaceFinished
+ ) &&
+ state.status === ShardingStatuses.UNMANAGING_NAMESPACE
+ ) {
+ return {
+ ...state,
+ managedNamespace: undefined,
status: ShardingStatuses.UNSHARDED,
};
}
+ if (
+ isAction(
+ action,
+ GlobalWritesActionTypes.UnmanagingNamespaceErrored
+ ) &&
+ state.status === ShardingStatuses.UNMANAGING_NAMESPACE
+ ) {
+ return {
+ ...state,
+ status: ShardingStatuses.SHARD_KEY_CORRECT,
+ };
+ }
+
return state;
};
export const fetchClusterShardingData =
- (): GlobalWritesThunkAction, IsManagedNamespaceFetchedAction> =>
+ (): GlobalWritesThunkAction, ManagedNamespaceFetchedAction> =>
async (
dispatch,
getState,
- { atlasGlobalWritesService, connectionInfoRef, logger }
+ { atlasGlobalWritesService, logger, connectionInfoRef }
) => {
- if (!connectionInfoRef.current.atlasMetadata) {
- return;
- }
-
const { namespace } = getState();
- const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata;
-
try {
// Call the API to check if the namespace is managed. If the namespace is managed,
// we would want to fetch more data that is needed to figure out the state and
// accordingly show the UI to the user.
- const isNamespaceManaged =
- await atlasGlobalWritesService.isNamespaceManaged(namespace, {
- projectId,
- clusterName,
- });
+ const managedNamespace =
+ await atlasGlobalWritesService.getManagedNamespace(namespace);
dispatch({
- type: GlobalWritesActionTypes.IsManagedNamespaceFetched,
- isNamespaceManaged,
+ type: GlobalWritesActionTypes.ManagedNamespaceFetched,
+ managedNamespace,
});
- if (!isNamespaceManaged) {
+ if (!managedNamespace) {
return;
}
- // TODO (COMPASS-8277): Now fetch the sharding key and possible process error.
+
+ // At this point, the namespace is managed and we want to fetch the sharding key.
+ void dispatch(fetchNamespaceShardKey());
} catch (error) {
logger.log.error(
logger.mongoLogId(1_001_000_330),
@@ -175,50 +369,44 @@ export const fetchClusterShardingData =
'Error fetching cluster sharding data',
(error as Error).message
);
- openToast('global-writes-fetch-shard-info-error', {
- title: `Failed to fetch sharding information: ${
- (error as Error).message
- }`,
- dismissible: true,
- timeout: 5000,
- variant: 'important',
- });
+ openToast(
+ `global-writes-fetch-shard-info-error-${connectionInfoRef.current.id}-${namespace}`,
+ {
+ title: `Failed to fetch sharding information: ${
+ (error as Error).message
+ }`,
+ dismissible: true,
+ timeout: 5000,
+ variant: 'important',
+ }
+ );
}
};
-export const createShardKey =
- (
- data: CreateShardKeyData
- ): GlobalWritesThunkAction<
- Promise,
- | SubmittingForShardingStartedAction
- | SubmittingForShardingFinishedAction
- | SubmittingForShardingErroredAction
- > =>
- async (
+export const createShardKey = (
+ data: CreateShardKeyData
+): GlobalWritesThunkAction<
+ Promise,
+ | SubmittingForShardingStartedAction
+ | SubmittingForShardingFinishedAction
+ | SubmittingForShardingErroredAction
+> => {
+ return async (
dispatch,
getState,
- { connectionInfoRef, atlasGlobalWritesService, logger }
+ { atlasGlobalWritesService, logger, connectionInfoRef }
) => {
- if (!connectionInfoRef.current.atlasMetadata) {
- return;
- }
-
const { namespace } = getState();
- const { clusterName, projectId } = connectionInfoRef.current.atlasMetadata;
-
dispatch({
type: GlobalWritesActionTypes.SubmittingForShardingStarted,
});
try {
- await atlasGlobalWritesService.createShardKey(namespace, data, {
- projectId,
- clusterName,
- });
- dispatch({
- type: GlobalWritesActionTypes.SubmittingForShardingFinished,
- });
+ const managedNamespace = await atlasGlobalWritesService.createShardKey(
+ namespace,
+ data
+ );
+ dispatch(setNamespaceBeingSharded(managedNamespace));
} catch (error) {
logger.log.error(
logger.mongoLogId(1_001_000_331),
@@ -229,16 +417,173 @@ export const createShardKey =
data,
}
);
- openToast('global-writes-create-shard-key-error', {
- title: `Failed to create shard key: ${(error as Error).message}`,
- dismissible: true,
- timeout: 5000,
- variant: 'important',
- });
+ openToast(
+ `global-writes-create-shard-key-error-${connectionInfoRef.current.id}-${namespace}`,
+ {
+ title: `Failed to create shard key: ${(error as Error).message}`,
+ dismissible: true,
+ timeout: 5000,
+ variant: 'important',
+ }
+ );
dispatch({
type: GlobalWritesActionTypes.SubmittingForShardingErrored,
});
}
};
+};
+
+const setNamespaceBeingSharded = (
+ managedNamespace?: ManagedNamespace
+): GlobalWritesThunkAction => {
+ return (dispatch) => {
+ dispatch({
+ type: GlobalWritesActionTypes.SubmittingForShardingFinished,
+ managedNamespace,
+ });
+ };
+};
+
+export const fetchNamespaceShardKey = (): GlobalWritesThunkAction<
+ Promise,
+ NamespaceShardingErrorFetchedAction | NamespaceShardKeyFetchedAction
+> => {
+ return async (
+ dispatch,
+ getState,
+ { atlasGlobalWritesService, logger, connectionInfoRef }
+ ) => {
+ const { namespace } = getState();
+
+ try {
+ const [shardingError, shardKey] = await Promise.all([
+ atlasGlobalWritesService.getShardingError(namespace),
+ atlasGlobalWritesService.getShardingKeys(namespace),
+ ]);
+
+ if (shardingError) {
+ dispatch({
+ type: GlobalWritesActionTypes.NamespaceShardingErrorFetched,
+ error: shardingError,
+ });
+ return;
+ }
+
+ if (!shardKey) {
+ dispatch(setNamespaceBeingSharded());
+ return;
+ }
+
+ dispatch({
+ type: GlobalWritesActionTypes.NamespaceShardKeyFetched,
+ shardKey,
+ });
+ void dispatch(fetchShardingZones());
+ } catch (error) {
+ logger.log.error(
+ logger.mongoLogId(1_001_000_333),
+ 'AtlasFetchError',
+ 'Error fetching shard key',
+ (error as Error).message
+ );
+ openToast(
+ `global-writes-fetch-shard-key-error-${connectionInfoRef.current.id}-${namespace}`,
+ {
+ title: `Failed to fetch shard key: ${(error as Error).message}`,
+ dismissible: true,
+ timeout: 5000,
+ variant: 'important',
+ }
+ );
+ }
+ };
+};
+
+export const fetchShardingZones = (): GlobalWritesThunkAction<
+ Promise,
+ ShardZonesFetchedAction
+> => {
+ return async (dispatch, getState, { atlasGlobalWritesService }) => {
+ const { shardZones } = getState();
+ if (shardZones.length > 0) {
+ return;
+ }
+ const shardingZones = await atlasGlobalWritesService.getShardingZones();
+ dispatch({
+ type: GlobalWritesActionTypes.ShardZonesFetched,
+ shardZones: shardingZones,
+ });
+ };
+};
+
+export const unmanageNamespace = (): GlobalWritesThunkAction<
+ Promise,
+ | UnmanagingNamespaceStartedAction
+ | UnmanagingNamespaceFinishedAction
+ | UnmanagingNamespaceErroredAction
+> => {
+ return async (
+ dispatch,
+ getState,
+ { atlasGlobalWritesService, connectionInfoRef }
+ ) => {
+ const { namespace } = getState();
+
+ dispatch({
+ type: GlobalWritesActionTypes.UnmanagingNamespaceStarted,
+ });
+
+ try {
+ await atlasGlobalWritesService.unmanageNamespace(namespace);
+ dispatch({
+ type: GlobalWritesActionTypes.UnmanagingNamespaceFinished,
+ });
+ } catch (error) {
+ dispatch({
+ type: GlobalWritesActionTypes.UnmanagingNamespaceErrored,
+ });
+ openToast(
+ `global-writes-unmanage-namespace-error-${connectionInfoRef.current.id}-${namespace}`,
+ {
+ title: `Failed to unmanage namespace: ${(error as Error).message}`,
+ dismissible: true,
+ timeout: 5000,
+ variant: 'important',
+ }
+ );
+ }
+ };
+};
+
+export function getStatusFromShardKey(
+ shardKey: ShardKey,
+ managedNamespace?: ManagedNamespace
+) {
+ const [firstShardKey, secondShardKey] = shardKey.fields;
+
+ // For a shard key to be valid:
+ // 1. The first key must be location and of type RANGE.
+ // 2. The second key name must match managedNamespace.customShardKey and
+ // the type must match the managedNamespace.isCustomShardKeyHashed.
+
+ const isLocatonKeyValid =
+ firstShardKey.name === 'location' && firstShardKey.type === 'RANGE';
+ const isCustomKeyValid =
+ managedNamespace &&
+ managedNamespace.isShardKeyUnique === shardKey.isUnique &&
+ secondShardKey.name === managedNamespace.customShardKey &&
+ secondShardKey.type ===
+ (managedNamespace.isCustomShardKeyHashed ? 'HASHED' : 'RANGE');
+
+ if (!isLocatonKeyValid || !secondShardKey) {
+ return ShardingStatuses.SHARD_KEY_INVALID;
+ }
+
+ if (!isCustomKeyValid) {
+ return ShardingStatuses.SHARD_KEY_MISMATCH;
+ }
+
+ return ShardingStatuses.SHARD_KEY_CORRECT;
+}
export default reducer;
diff --git a/packages/compass-global-writes/tests/create-store.tsx b/packages/compass-global-writes/tests/create-store.tsx
index 0aad67f78dc..631a6d6d5d9 100644
--- a/packages/compass-global-writes/tests/create-store.tsx
+++ b/packages/compass-global-writes/tests/create-store.tsx
@@ -7,13 +7,25 @@ import { activateGlobalWritesPlugin } from '../src/store';
import { createActivateHelpers } from 'hadron-app-registry';
import { createNoopLogger } from '@mongodb-js/compass-logging/provider';
import { createNoopTrack } from '@mongodb-js/compass-telemetry/provider';
-import type { ConnectionInfoRef } from '@mongodb-js/compass-connections/provider';
+import type { ConnectionInfo } from '@mongodb-js/compass-connections/provider';
import type { AtlasService } from '@mongodb-js/atlas-service/provider';
import { Provider } from 'react-redux';
-import { render } from '@mongodb-js/testing-library-compass';
+import { renderWithActiveConnection } from '@mongodb-js/testing-library-compass';
import clusterApiResponse from './cluster-api-response.json';
+const TEST_CONNECTION_INFO = {
+ id: 'TEST',
+ connectionOptions: {
+ connectionString: 'mongodb://localhost',
+ },
+ atlasMetadata: {
+ clusterName: 'Cluster0',
+ clusterType: 'UNSHARDED',
+ projectId: 'Project0',
+ } as unknown as ConnectionInfo['atlasMetadata'],
+};
+
const atlasService = {
cloudEndpoint: (p: string) => {
return `https://example.com/${p}`;
@@ -36,19 +48,9 @@ const atlasService = {
export const setupStore = (
options: Partial = {},
- services: Partial = {}
+ services: Partial = {},
+ connectionInfo: ConnectionInfo = TEST_CONNECTION_INFO
) => {
- const connectionInfoRef = {
- current: {
- id: 'TEST',
- atlasMetadata: {
- clusterName: 'Cluster0',
- clusterType: 'GEOSHARDED',
- projectId: 'Project0',
- },
- },
- } as ConnectionInfoRef;
-
return activateGlobalWritesPlugin(
{
namespace: 'airbnb.listings',
@@ -57,7 +59,12 @@ export const setupStore = (
{
logger: createNoopLogger('TEST'),
track: createNoopTrack(),
- connectionInfoRef,
+ connectionInfoRef: {
+ current: {
+ ...connectionInfo,
+ title: 'My connection',
+ },
+ },
...services,
atlasService: {
...atlasService,
@@ -70,10 +77,19 @@ export const setupStore = (
export const renderWithStore = (
component: JSX.Element,
- services: Partial = {},
- options: Partial = {}
+ {
+ services = {},
+ options = {},
+ connectionInfo = TEST_CONNECTION_INFO,
+ }: {
+ services?: Partial;
+ options?: Partial;
+ connectionInfo?: ConnectionInfo;
+ } = {}
) => {
- const store = setupStore(options, services);
- render({component} );
- return store;
+ const store = setupStore(options, services, connectionInfo);
+ return renderWithActiveConnection(
+ {component} ,
+ connectionInfo
+ );
};